Skip to main content

tdsl_render/
html.rs

1/// Wrap a pre-rendered SVG string in a standalone HTML document with embedded CSS.
2pub fn wrap_html(
3    svg_body: &str,
4    title: &str,
5    opts: &crate::layout::RenderOptions,
6    table_html: Option<&str>,
7) -> String {
8    use crate::layout::Theme;
9
10    let theme_css = match opts.theme {
11        Theme::Default => "",
12        Theme::Dark => DARK_THEME_CSS,
13        Theme::Print => PRINT_THEME_CSS,
14        Theme::Pastel => PASTEL_THEME_CSS,
15    };
16
17    let custom_css_block = match &opts.custom_css {
18        Some(css) => format!("\n<style>\n{css}\n</style>"),
19        None => String::new(),
20    };
21
22    let table_block = match table_html {
23        Some(t) => format!("\n<div class=\"tdsl-table-wrap\">\n{t}\n</div>"),
24        None => String::new(),
25    };
26
27    format!(
28        r#"<!DOCTYPE html>
29<html lang="ja">
30<head>
31<meta charset="UTF-8">
32<meta name="viewport" content="width=device-width, initial-scale=1">
33<title>{title}</title>
34<link rel="preconnect" href="https://fonts.googleapis.com">
35<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
36<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap" rel="stylesheet">
37<style>
38{css}
39{theme_css}</style>{custom_css_block}
40</head>
41<body>
42<h1>{title}</h1>
43<div class="tdsl-timeline">
44{svg}
45</div>{table_block}
46<div id="tdsl-tooltip" class="tdsl-tooltip" role="tooltip" hidden aria-hidden="true"></div>
47<script>
48{js}
49</script>
50</body>
51</html>
52"#,
53        title = escape_html(title),
54        css = EMBEDDED_CSS,
55        theme_css = theme_css,
56        custom_css_block = custom_css_block,
57        svg = svg_body,
58        table_block = table_block,
59        js = EMBEDDED_JS,
60    )
61}
62
63const EMBEDDED_CSS: &str = r#"body {
64  font-family: "Noto Sans JP", "Noto Sans CJK JP", "Hiragino Sans", "Yu Gothic UI",
65    "Yu Gothic", "Meiryo", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
66  margin: 24px;
67  color: #222;
68  background: #fafafa;
69}
70h1 {
71  font-size: 18px;
72  margin: 0 0 16px;
73  font-weight: 600;
74}
75.tdsl-timeline {
76  background: #fff;
77  border: 1px solid #e5e5e5;
78  border-radius: 4px;
79  padding: 8px;
80  overflow-x: auto;
81}
82.tdsl-timeline svg {
83  display: block;
84}
85.tdsl-lane-band-even { fill: #fff; }
86.tdsl-lane-band-odd  { fill: #f5f5f7; }
87.tdsl-axis-baseline       { stroke: #888; stroke-width: 1; }
88.tdsl-axis-tick           { stroke: #e0e0e0; stroke-width: 1; }
89.tdsl-axis-month-tick     { stroke: #ccc; stroke-width: 1; }
90.tdsl-axis-text           { font-size: 11px; fill: #666; }
91.tdsl-lane-label     { font-size: 13px; fill: #333; font-weight: 500; }
92.tdsl-span {
93  fill: #4682B4;
94  fill-opacity: 0.78;
95  stroke: #2a4d6e;
96  stroke-width: 1;
97  cursor: pointer;
98  transition: fill-opacity 0.15s;
99}
100.tdsl-span:hover { fill-opacity: 1; }
101.tdsl-event-range {
102  fill: #DC143C;
103  fill-opacity: 0.75;
104  stroke: #8b0c1a;
105  stroke-width: 1;
106  cursor: pointer;
107  transition: fill-opacity 0.15s;
108}
109.tdsl-event-range:hover { fill-opacity: 1; }
110.tdsl-event-dot {
111  fill: #333;
112  stroke: #fff;
113  stroke-width: 1;
114  cursor: pointer;
115}
116.tdsl-event-dot:hover { fill: #1a73e8; }
117.tdsl-event-stem     { stroke: #aaa; stroke-width: 1; stroke-dasharray: 2 2; }
118/* Invisible but hoverable hit-area so the thin stem + tiny dot are easy to hover for tooltips. */
119.tdsl-event-hit      { fill: transparent; cursor: pointer; }
120.tdsl-item-label {
121  font-size: 11px;
122  fill: #fff;
123  pointer-events: none;
124  font-weight: 500;
125}
126.tdsl-event-label {
127  font-size: 10px;
128  fill: #333;
129  pointer-events: none;
130  user-select: none;
131}
132.tdsl-item:focus-visible .tdsl-span,
133.tdsl-item:focus-visible .tdsl-event-range,
134.tdsl-item:focus-visible .tdsl-event-dot {
135  stroke: #1a73e8;
136  stroke-width: 2;
137}
138.tdsl-tooltip {
139  position: fixed;
140  left: 0;
141  top: 0;
142  z-index: 9999;
143  max-width: min(360px, calc(100vw - 16px));
144  padding: 8px 10px;
145  border-radius: 6px;
146  border: 1px solid #d0d7de;
147  background: rgba(255, 255, 255, 0.96);
148  color: #111;
149  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
150  font-size: 12px;
151  line-height: 1.45;
152  white-space: pre-line;
153  pointer-events: none;
154}
155.tdsl-table-wrap { margin-top: 2rem; overflow-x: auto; }
156.tdsl-table { width: 100%; border-collapse: collapse; font-size: 0.9em; }
157.tdsl-table th, .tdsl-table td { border: 1px solid #ccc; padding: 0.4em 0.6em; text-align: left; }
158.tdsl-table th { background: #f5f5f5; font-weight: bold; position: sticky; top: 0; }
159.tdsl-table tbody tr:nth-child(even) { background: #fafafa; }
160"#;
161
162/// Dark theme overrides — deep navy background, light text.
163const DARK_THEME_CSS: &str = r#"body {
164  background: #1a1a2e;
165  color: #e0e0e0;
166}
167h1 { color: #e0e0e0; }
168.tdsl-timeline {
169  background: #16213e;
170  border-color: #0f3460;
171}
172.tdsl-lane-band-even { fill: #16213e; }
173.tdsl-lane-band-odd  { fill: #0f3460; }
174.tdsl-axis-baseline       { stroke: #555; }
175.tdsl-axis-tick           { stroke: #2a4a7f; }
176.tdsl-axis-month-tick     { stroke: #1e3a5f; }
177.tdsl-axis-text           { fill: #aaa; }
178.tdsl-lane-label     { fill: #ccc; }
179.tdsl-item-label     { fill: #f0f0f0; }
180.tdsl-tooltip {
181  background: rgba(22, 33, 62, 0.96);
182  border-color: #0f3460;
183  color: #e0e0e0;
184}
185"#;
186
187/// Print theme overrides — monochrome, high contrast.
188const PRINT_THEME_CSS: &str = r#"body {
189  background: #fff;
190  color: #000;
191}
192.tdsl-timeline {
193  background: #fff;
194  border-color: #000;
195}
196.tdsl-lane-band-even { fill: #fff; }
197.tdsl-lane-band-odd  { fill: #eee; }
198.tdsl-axis-baseline       { stroke: #000; }
199.tdsl-axis-tick           { stroke: #bbb; }
200.tdsl-axis-month-tick     { stroke: #999; }
201.tdsl-axis-text           { fill: #000; }
202.tdsl-lane-label     { fill: #000; }
203.tdsl-span {
204  fill: #333;
205  stroke: #000;
206}
207.tdsl-event-range {
208  fill: #666;
209  stroke: #000;
210}
211.tdsl-event-dot      { fill: #000; }
212.tdsl-item-label     { fill: #fff; }
213@media print {
214  .tdsl-table th { background: #333 !important; color: #fff !important; }
215  .tdsl-table th, .tdsl-table td { border-color: #333; }
216  thead { display: table-header-group; }
217}
218"#;
219
220/// Pastel theme overrides — soft, light colors with rounded spans.
221const PASTEL_THEME_CSS: &str = r#"body {
222  background: #fef9f0;
223  color: #444;
224}
225.tdsl-timeline {
226  background: #fffdf7;
227  border-color: #e8dcc8;
228}
229.tdsl-lane-band-even { fill: #fffdf7; }
230.tdsl-lane-band-odd  { fill: #fdf3e3; }
231.tdsl-axis-baseline       { stroke: #ccc; }
232.tdsl-axis-tick           { stroke: #e8dcc8; }
233.tdsl-axis-month-tick     { stroke: #ddd; }
234.tdsl-axis-text           { fill: #888; }
235.tdsl-lane-label     { fill: #666; }
236.tdsl-span {
237  fill: #b5d5f5;
238  stroke: #7aabdf;
239  rx: 6;
240}
241.tdsl-event-range {
242  fill: #f5c6b5;
243  stroke: #df8a7a;
244}
245.tdsl-event-dot      { fill: #888; stroke: #fff; }
246.tdsl-item-label     { fill: #333; }
247"#;
248
249const EMBEDDED_JS: &str = r#"(() => {
250  const tooltip = document.getElementById("tdsl-tooltip");
251  if (!tooltip) return;
252
253  const items = document.querySelectorAll(".tdsl-item[data-tdsl-tooltip]");
254  if (!items.length) return;
255
256  const GAP = 12;
257  const PAD = 8;
258
259  const hide = () => {
260    tooltip.hidden = true;
261    tooltip.setAttribute("aria-hidden", "true");
262  };
263
264  const show = (text) => {
265    if (!text) return;
266    tooltip.textContent = text;
267    tooltip.hidden = false;
268    tooltip.setAttribute("aria-hidden", "false");
269  };
270
271  const move = (clientX, clientY) => {
272    if (tooltip.hidden) return;
273    const rect = tooltip.getBoundingClientRect();
274    let x = clientX + GAP;
275    let y = clientY + GAP;
276
277    if (x + rect.width > window.innerWidth - PAD) {
278      x = Math.max(PAD, clientX - rect.width - GAP);
279    }
280    if (y + rect.height > window.innerHeight - PAD) {
281      y = Math.max(PAD, clientY - rect.height - GAP);
282    }
283
284    tooltip.style.left = `${x}px`;
285    tooltip.style.top = `${y}px`;
286  };
287
288  const showAtElement = (el) => {
289    const text = el.getAttribute("data-tdsl-tooltip");
290    if (!text) return;
291    show(text);
292    const box = el.getBoundingClientRect();
293    move(box.left + box.width / 2, box.top + box.height / 2);
294  };
295
296  for (const el of items) {
297    el.addEventListener("pointerenter", (event) => {
298      show(el.getAttribute("data-tdsl-tooltip"));
299      move(event.clientX, event.clientY);
300    });
301    el.addEventListener("pointermove", (event) => {
302      move(event.clientX, event.clientY);
303    });
304    el.addEventListener("pointerleave", hide);
305    el.addEventListener("focus", () => showAtElement(el));
306    el.addEventListener("blur", hide);
307  }
308
309  document.addEventListener("keydown", (event) => {
310    if (event.key === "Escape") hide();
311  });
312  window.addEventListener("scroll", hide, { passive: true });
313  window.addEventListener("resize", hide);
314})();"#;
315
316fn escape_html(s: &str) -> String {
317    let mut out = String::with_capacity(s.len());
318    for c in s.chars() {
319        match c {
320            '&' => out.push_str("&amp;"),
321            '<' => out.push_str("&lt;"),
322            '>' => out.push_str("&gt;"),
323            '"' => out.push_str("&quot;"),
324            '\'' => out.push_str("&#39;"),
325            _ => out.push(c),
326        }
327    }
328    out
329}
330
331/// Column header names for the item table.
332const TABLE_COL_TIME: &str = "時期";
333const TABLE_COL_LABEL: &str = "ラベル";
334const TABLE_COL_LANE: &str = "レーン";
335const TABLE_COL_TAGS: &str = "タグ";
336
337/// Internal row representation for table generation.
338struct TableRow {
339    /// Sort key: start/time year for ordering.
340    sort_year: i64,
341    /// Sort secondary key: item type order (0=span, 1=event_range, 2=event).
342    sort_type: u8,
343    /// Formatted time period string (e.g. "206 BC〜220" or "1944 Jun 6").
344    time_str: String,
345    label: String,
346    lane_label: String,
347    tags: String,
348}
349
350/// Generate an HTML table listing all items from the IR in chronological order.
351///
352/// Columns: 時期 (time period) / ラベル (label) / レーン (lane) / タグ (tags).
353/// Items are sorted by start/time year ascending, then by item type (span > event_range > event),
354/// then by label for ties.
355pub(crate) fn generate_table_html(
356    ir: &tdsl_core::ir::TimelineIr,
357    lanes: &[tdsl_core::ir::Lane],
358) -> String {
359    use crate::layout::format_date;
360    use tdsl_core::ir::Item;
361
362    let lane_label = |lane_id: &str| -> String {
363        lanes
364            .iter()
365            .find(|l| l.id == lane_id)
366            .map(|l| l.label.clone())
367            .unwrap_or_else(|| lane_id.to_string())
368    };
369
370    let mut rows: Vec<TableRow> = ir
371        .items
372        .iter()
373        .map(|item| match item {
374            Item::Span {
375                label,
376                lane,
377                start,
378                end,
379                tags,
380                start_month,
381                start_day,
382                end_month,
383                end_day,
384                ..
385            } => TableRow {
386                sort_year: *start,
387                sort_type: 0,
388                time_str: format!(
389                    "{}〜{}",
390                    format_date(*start, *start_month, *start_day),
391                    format_date(*end, *end_month, *end_day),
392                ),
393                label: label.clone(),
394                lane_label: lane_label(lane),
395                tags: tags.join(", "),
396            },
397            Item::EventRange {
398                label,
399                lane,
400                start,
401                end,
402                tags,
403                start_month,
404                start_day,
405                end_month,
406                end_day,
407                ..
408            } => TableRow {
409                sort_year: *start,
410                sort_type: 1,
411                time_str: format!(
412                    "{}〜{}",
413                    format_date(*start, *start_month, *start_day),
414                    format_date(*end, *end_month, *end_day),
415                ),
416                label: label.clone(),
417                lane_label: lane_label(lane),
418                tags: tags.join(", "),
419            },
420            Item::Event {
421                label,
422                lane,
423                time,
424                tags,
425                time_month,
426                time_day,
427                ..
428            } => TableRow {
429                sort_year: *time,
430                sort_type: 2,
431                time_str: format_date(*time, *time_month, *time_day),
432                label: label.clone(),
433                lane_label: lane_label(lane),
434                tags: tags.join(", "),
435            },
436        })
437        .collect();
438
439    rows.sort_by(|a, b| {
440        a.sort_year
441            .cmp(&b.sort_year)
442            .then(a.sort_type.cmp(&b.sort_type))
443            .then(a.label.cmp(&b.label))
444    });
445
446    let mut html = String::new();
447    html.push_str("<table class=\"tdsl-table\">\n");
448    html.push_str("<thead>\n<tr>");
449    for col in [
450        TABLE_COL_TIME,
451        TABLE_COL_LABEL,
452        TABLE_COL_LANE,
453        TABLE_COL_TAGS,
454    ] {
455        html.push_str(&format!("<th>{}</th>", escape_html(col)));
456    }
457    html.push_str("</tr>\n</thead>\n<tbody>\n");
458
459    for row in &rows {
460        html.push_str("<tr>");
461        html.push_str(&format!("<td>{}</td>", escape_html(&row.time_str)));
462        html.push_str(&format!("<td>{}</td>", escape_html(&row.label)));
463        html.push_str(&format!("<td>{}</td>", escape_html(&row.lane_label)));
464        html.push_str(&format!("<td>{}</td>", escape_html(&row.tags)));
465        html.push_str("</tr>\n");
466    }
467
468    html.push_str("</tbody>\n</table>");
469    html
470}
471
472/// Wrap a pre-rendered SVG string in an interactive standalone HTML document.
473///
474/// The interactive document adds zoom/pan, search filtering, click detail panel,
475/// and lane legend checkboxes — all via inline JS/CSS with no external CDN deps.
476pub fn wrap_html_interactive(
477    svg_body: &str,
478    title: &str,
479    opts: &crate::layout::RenderOptions,
480    lanes: &[tdsl_core::ir::Lane],
481    table_html: Option<&str>,
482) -> String {
483    use crate::layout::Theme;
484
485    let theme_css = match opts.theme {
486        Theme::Default => "",
487        Theme::Dark => DARK_THEME_CSS,
488        Theme::Print => PRINT_THEME_CSS,
489        Theme::Pastel => PASTEL_THEME_CSS,
490    };
491
492    let custom_css_block = match &opts.custom_css {
493        Some(css) => format!("\n<style>\n{css}\n</style>"),
494        None => String::new(),
495    };
496
497    // Generate legend HTML from lane data.
498    let mut legend_html = String::new();
499    for lane in lanes {
500        let lane_id_escaped = escape_html(&lane.id);
501        let lane_label_escaped = escape_html(&lane.label);
502        legend_html.push_str(&format!(
503            r#"<label class="tdsl-legend-item"><input type="checkbox" checked data-lane-toggle="{lane_id_escaped}"> {lane_label_escaped}</label>"#,
504        ));
505    }
506
507    let table_block = match table_html {
508        Some(t) => format!("\n<div class=\"tdsl-table-wrap\">\n{t}\n</div>"),
509        None => String::new(),
510    };
511
512    format!(
513        r#"<!DOCTYPE html>
514<html lang="ja">
515<head>
516<meta charset="UTF-8">
517<meta name="viewport" content="width=device-width, initial-scale=1">
518<title>{title}</title>
519<link rel="preconnect" href="https://fonts.googleapis.com">
520<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
521<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+JP:wght@400;700&display=swap" rel="stylesheet">
522<style>
523{css}
524{theme_css}{interactive_css}</style>{custom_css_block}
525</head>
526<body>
527<div id="tdsl-app">
528  <header id="tdsl-header">
529    <h1>{title}</h1>
530    <input id="tdsl-search" type="search" placeholder="ラベル検索..." autocomplete="off">
531  </header>
532  <div id="tdsl-main">
533    <div id="tdsl-legend">
534      {legend_html}
535    </div>
536    <div id="tdsl-canvas">
537      {svg}
538    </div>
539    <div id="tdsl-detail" hidden>
540      <button id="tdsl-detail-close" aria-label="閉じる">&times;</button>
541      <div id="tdsl-detail-content"></div>
542    </div>
543  </div>
544</div>{table_block}
545<div id="tdsl-tooltip" class="tdsl-tooltip" role="tooltip" hidden aria-hidden="true"></div>
546<script>
547{js}
548</script>
549</body>
550</html>
551"#,
552        title = escape_html(title),
553        css = EMBEDDED_CSS,
554        theme_css = theme_css,
555        interactive_css = INTERACTIVE_CSS,
556        custom_css_block = custom_css_block,
557        legend_html = legend_html,
558        svg = svg_body,
559        table_block = table_block,
560        js = INTERACTIVE_JS,
561    )
562}
563
564const INTERACTIVE_CSS: &str = r#"
565/* Interactive mode layout */
566#tdsl-app {
567  display: flex;
568  flex-direction: column;
569  height: 100vh;
570  margin: 0;
571  overflow: hidden;
572}
573#tdsl-header {
574  display: flex;
575  align-items: center;
576  gap: 16px;
577  padding: 8px 16px;
578  background: #fff;
579  border-bottom: 1px solid #e5e5e5;
580  flex-shrink: 0;
581}
582#tdsl-header h1 {
583  margin: 0;
584  font-size: 16px;
585}
586#tdsl-search {
587  padding: 5px 10px;
588  border: 1px solid #ccc;
589  border-radius: 4px;
590  font-size: 13px;
591  width: 200px;
592  outline-offset: 2px;
593}
594#tdsl-main {
595  display: flex;
596  flex: 1;
597  overflow: hidden;
598}
599#tdsl-legend {
600  width: 160px;
601  flex-shrink: 0;
602  padding: 12px 8px;
603  border-right: 1px solid #e5e5e5;
604  overflow-y: auto;
605  background: #fafafa;
606  font-size: 12px;
607}
608.tdsl-legend-item {
609  display: flex;
610  align-items: center;
611  gap: 6px;
612  padding: 3px 0;
613  cursor: pointer;
614  user-select: none;
615}
616#tdsl-canvas {
617  flex: 1;
618  overflow: auto;
619  position: relative;
620  cursor: grab;
621}
622#tdsl-canvas.dragging {
623  cursor: grabbing;
624}
625#tdsl-canvas svg {
626  display: block;
627}
628#tdsl-detail {
629  width: 240px;
630  flex-shrink: 0;
631  padding: 12px;
632  border-left: 1px solid #e5e5e5;
633  background: #fafafa;
634  font-size: 13px;
635  overflow-y: auto;
636  position: relative;
637}
638#tdsl-detail[hidden] {
639  display: none;
640}
641#tdsl-detail-close {
642  position: absolute;
643  top: 8px;
644  right: 8px;
645  background: none;
646  border: none;
647  font-size: 18px;
648  cursor: pointer;
649  color: #666;
650  line-height: 1;
651  padding: 2px 6px;
652}
653#tdsl-detail-close:hover {
654  color: #111;
655}
656#tdsl-detail-content {
657  margin-top: 4px;
658}
659#tdsl-detail-content dl {
660  margin: 0;
661  display: grid;
662  grid-template-columns: auto 1fr;
663  gap: 4px 8px;
664}
665#tdsl-detail-content dt {
666  font-weight: 600;
667  color: #555;
668  white-space: nowrap;
669}
670#tdsl-detail-content dd {
671  margin: 0;
672  word-break: break-all;
673}
674/* Search highlight / dim */
675.tdsl-item.tdsl-search-dim {
676  opacity: 0.15;
677}
678.tdsl-item.tdsl-search-match .tdsl-span,
679.tdsl-item.tdsl-search-match .tdsl-event-range,
680.tdsl-item.tdsl-search-match .tdsl-event-dot {
681  stroke: #f5c000;
682  stroke-width: 2.5;
683}
684/* Lane hide */
685.tdsl-item.tdsl-lane-hidden {
686  display: none;
687}
688"#;
689
690const INTERACTIVE_JS: &str = r#"(() => {
691  // ── Tooltip (reuse existing logic) ──────────────────────────────────────
692  const tooltip = document.getElementById("tdsl-tooltip");
693  const items = document.querySelectorAll(".tdsl-item[data-tdsl-tooltip]");
694  if (tooltip && items.length) {
695    const GAP = 12, PAD = 8;
696    const hide = () => { tooltip.hidden = true; tooltip.setAttribute("aria-hidden","true"); };
697    const show = (text) => {
698      if (!text) return;
699      tooltip.textContent = text;
700      tooltip.hidden = false;
701      tooltip.setAttribute("aria-hidden","false");
702    };
703    const move = (cx, cy) => {
704      if (tooltip.hidden) return;
705      const r = tooltip.getBoundingClientRect();
706      let x = cx + GAP, y = cy + GAP;
707      if (x + r.width  > window.innerWidth  - PAD) x = Math.max(PAD, cx - r.width  - GAP);
708      if (y + r.height > window.innerHeight - PAD) y = Math.max(PAD, cy - r.height - GAP);
709      tooltip.style.left = x + "px";
710      tooltip.style.top  = y + "px";
711    };
712    const showAt = (el) => {
713      const text = el.getAttribute("data-tdsl-tooltip");
714      if (!text) return;
715      show(text);
716      const box = el.getBoundingClientRect();
717      move(box.left + box.width / 2, box.top + box.height / 2);
718    };
719    for (const el of items) {
720      el.addEventListener("pointerenter", (e) => { show(el.getAttribute("data-tdsl-tooltip")); move(e.clientX, e.clientY); });
721      el.addEventListener("pointermove",  (e) => move(e.clientX, e.clientY));
722      el.addEventListener("pointerleave", hide);
723      el.addEventListener("focus", () => showAt(el));
724      el.addEventListener("blur",  hide);
725    }
726    document.addEventListener("keydown", (e) => { if (e.key === "Escape") hide(); });
727    window.addEventListener("scroll", hide, { passive: true });
728    window.addEventListener("resize", hide);
729  }
730
731  // ── Zoom (wheel) + Pan (drag) ────────────────────────────────────────────
732  const canvas = document.getElementById("tdsl-canvas");
733  if (canvas) {
734    const svg = canvas.querySelector("svg");
735    let svgBaseWidth = svg ? parseFloat(svg.getAttribute("width") || "0") : 0;
736    let zoomLevel = 1.0;
737
738    // Zoom: scale SVG width on wheel; canvas overflow:auto provides scrollbars.
739    if (svg && svgBaseWidth > 0) {
740      canvas.addEventListener("wheel", (e) => {
741        e.preventDefault();
742        const factor = e.deltaY < 0 ? 1.12 : 0.893;
743        zoomLevel = Math.min(10, Math.max(0.25, zoomLevel * factor));
744        // Preserve the hovered x-position after zoom.
745        const canvasRect = canvas.getBoundingClientRect();
746        const mouseX = e.clientX - canvasRect.left + canvas.scrollLeft;
747        const ratio = mouseX / (svgBaseWidth * zoomLevel / factor);
748        svg.style.width = (svgBaseWidth * zoomLevel) + "px";
749        canvas.scrollLeft = ratio * svgBaseWidth * zoomLevel - (e.clientX - canvasRect.left);
750      }, { passive: false });
751    }
752
753    // Pan: drag to scroll horizontally.
754    let dragging = false;
755    let startX = 0, startScrollLeft = 0;
756    canvas.addEventListener("mousedown", (e) => {
757      if (e.button !== 0) return;
758      dragging = true;
759      startX = e.clientX;
760      startScrollLeft = canvas.scrollLeft;
761      canvas.classList.add("dragging");
762    });
763    document.addEventListener("mousemove", (e) => {
764      if (!dragging) return;
765      const dx = e.clientX - startX;
766      canvas.scrollLeft = startScrollLeft - dx;
767    });
768    document.addEventListener("mouseup", () => {
769      if (dragging) {
770        dragging = false;
771        canvas.classList.remove("dragging");
772      }
773    });
774  }
775
776  // ── Search filter ────────────────────────────────────────────────────────
777  const searchInput = document.getElementById("tdsl-search");
778  const allItems = Array.from(document.querySelectorAll(".tdsl-item[data-label]"));
779  if (searchInput && allItems.length) {
780    searchInput.addEventListener("input", () => {
781      const q = searchInput.value.trim().toLowerCase();
782      for (const el of allItems) {
783        const label = (el.getAttribute("data-label") || "").toLowerCase();
784        if (!q) {
785          el.classList.remove("tdsl-search-dim", "tdsl-search-match");
786        } else if (label.includes(q)) {
787          el.classList.remove("tdsl-search-dim");
788          el.classList.add("tdsl-search-match");
789        } else {
790          el.classList.remove("tdsl-search-match");
791          el.classList.add("tdsl-search-dim");
792        }
793      }
794    });
795  }
796
797  // ── Click detail panel ───────────────────────────────────────────────────
798  const detail = document.getElementById("tdsl-detail");
799  const detailContent = document.getElementById("tdsl-detail-content");
800  const detailClose = document.getElementById("tdsl-detail-close");
801  if (detail && detailContent && allItems.length) {
802    const showDetail = (el) => {
803      const label  = el.getAttribute("data-label")  || "";
804      const type_  = el.getAttribute("data-type")   || "";
805      const lane   = el.getAttribute("data-lane")   || "";
806      const source = el.getAttribute("data-source") || "";
807      let html = "<dl>";
808      if (label)  html += "<dt>ラベル</dt><dd>" + escapeHtml(label) + "</dd>";
809      if (type_)  html += "<dt>種別</dt><dd>"   + escapeHtml(type_) + "</dd>";
810      if (lane)   html += "<dt>レーン</dt><dd>"  + escapeHtml(lane)  + "</dd>";
811      if (source) {
812        const wd = source.match(/^wd:(Q\d+)$/);
813        if (wd) {
814          html += "<dt>出典</dt><dd><a href='https://www.wikidata.org/wiki/" + wd[1] + "' target='_blank' rel='noopener'>" + escapeHtml(source) + "</a></dd>";
815        } else {
816          html += "<dt>出典</dt><dd>" + escapeHtml(source) + "</dd>";
817        }
818      }
819      html += "</dl>";
820      detailContent.innerHTML = html;
821      detail.hidden = false;
822    };
823    for (const el of allItems) {
824      el.addEventListener("click", () => showDetail(el));
825    }
826    if (detailClose) {
827      detailClose.addEventListener("click", () => { detail.hidden = true; });
828    }
829    document.addEventListener("keydown", (e) => { if (e.key === "Escape") detail.hidden = true; });
830  }
831
832  // ── Legend toggles ───────────────────────────────────────────────────────
833  const laneToggles = document.querySelectorAll("[data-lane-toggle]");
834  if (laneToggles.length) {
835    const laneItemMap = {};
836    for (const el of allItems) {
837      const l = el.getAttribute("data-lane") || "";
838      if (!laneItemMap[l]) laneItemMap[l] = [];
839      laneItemMap[l].push(el);
840    }
841    for (const cb of laneToggles) {
842      cb.addEventListener("change", () => {
843        const laneId = cb.getAttribute("data-lane-toggle");
844        const visible = cb.checked;
845        for (const el of (laneItemMap[laneId] || [])) {
846          el.classList.toggle("tdsl-lane-hidden", !visible);
847        }
848      });
849    }
850  }
851
852  // ── Utility ─────────────────────────────────────────────────────────────
853  function escapeHtml(s) {
854    return String(s)
855      .replace(/&/g, "&amp;")
856      .replace(/</g, "&lt;")
857      .replace(/>/g, "&gt;")
858      .replace(/"/g, "&quot;")
859      .replace(/'/g, "&#39;");
860  }
861})();"#;
862
863#[cfg(test)]
864mod tests {
865    use super::*;
866    use crate::layout::RenderOptions;
867    use crate::layout::Theme;
868
869    #[test]
870    fn html_wraps_with_doctype_and_svg() {
871        let opts = RenderOptions::default();
872        let html = wrap_html("<svg></svg>", "test title", &opts, None);
873        assert!(html.starts_with("<!DOCTYPE html>"));
874        assert!(html.contains("<title>test title</title>"));
875        assert!(html.contains("<style>"));
876        assert!(html.contains("<svg></svg>"));
877        assert!(html.contains(r#"id="tdsl-tooltip""#));
878        assert!(html.contains(r#"data-tdsl-tooltip"#));
879    }
880
881    #[test]
882    fn html_escapes_title() {
883        let opts = RenderOptions::default();
884        let html = wrap_html("<svg></svg>", "A & B <danger>", &opts, None);
885        assert!(html.contains("A &amp; B &lt;danger&gt;"));
886        assert!(!html.contains("<danger>"));
887    }
888
889    #[test]
890    fn dark_theme_applies_dark_background() {
891        let opts = RenderOptions {
892            theme: Theme::Dark,
893            ..Default::default()
894        };
895        let html = wrap_html("<svg></svg>", "test", &opts, None);
896        assert!(html.contains("1a1a2e"), "dark theme should include #1a1a2e");
897    }
898
899    #[test]
900    fn custom_css_is_injected() {
901        let opts = RenderOptions {
902            custom_css: Some(".tdsl-span { fill: hotpink; }".into()),
903            ..Default::default()
904        };
905        let html = wrap_html("<svg></svg>", "test", &opts, None);
906        assert!(html.contains("hotpink"), "custom CSS should be in output");
907    }
908
909    #[test]
910    fn print_theme_applies_monochrome_background() {
911        let opts = RenderOptions {
912            theme: Theme::Print,
913            ..Default::default()
914        };
915        let html = wrap_html("<svg></svg>", "test", &opts, None);
916        // Print theme uses white background and black text
917        assert!(
918            html.contains("#fff") || html.contains("#ffffff"),
919            "print theme should include white background"
920        );
921        assert!(
922            html.contains("#000") || html.contains("#000000"),
923            "print theme should include black foreground"
924        );
925    }
926
927    #[test]
928    fn pastel_theme_applies_soft_colors() {
929        let opts = RenderOptions {
930            theme: Theme::Pastel,
931            ..Default::default()
932        };
933        let html = wrap_html("<svg></svg>", "test", &opts, None);
934        // Pastel theme uses #fef9f0 as background
935        assert!(
936            html.contains("fef9f0"),
937            "pastel theme should include #fef9f0 background"
938        );
939        // Pastel theme uses rounded spans (rx: 6)
940        assert!(
941            html.contains("b5d5f5"),
942            "pastel theme should include pastel blue span color"
943        );
944    }
945
946    #[test]
947    fn table_html_is_inserted_when_some() {
948        let opts = RenderOptions::default();
949        let table = "<table class=\"tdsl-table\"><thead><tr><th>時期</th></tr></thead><tbody></tbody></table>";
950        let html = wrap_html("<svg></svg>", "test", &opts, Some(table));
951        assert!(
952            html.contains("<div class=\"tdsl-table-wrap\">"),
953            "wrap_html with table_html=Some should include the table-wrap div element"
954        );
955        assert!(
956            html.contains("<table class=\"tdsl-table\">"),
957            "wrap_html with table_html=Some should include the table element"
958        );
959    }
960
961    #[test]
962    fn table_html_absent_when_none() {
963        let opts = RenderOptions::default();
964        let html = wrap_html("<svg></svg>", "test", &opts, None);
965        assert!(
966            !html.contains("<div class=\"tdsl-table-wrap\">"),
967            "wrap_html with table_html=None must not include the table-wrap div element"
968        );
969    }
970
971    #[test]
972    fn generate_table_html_basic() {
973        use tdsl_core::ir::{Item, Lane, Meta, TimelineIr};
974
975        let ir = TimelineIr {
976            meta: Meta {
977                title: "テスト".into(),
978                unit: "year".into(),
979                range: (-300, 300),
980                calendar: "proleptic_gregorian".into(),
981                color_map: std::collections::HashMap::new(),
982                ..Default::default()
983            },
984            lanes: vec![Lane {
985                id: "han".into(),
986                label: "漢".into(),
987                kind: "dynasty".into(),
988                order: 10,
989                group: None,
990                source_span: None,
991            }],
992            items: vec![
993                Item::Span {
994                    id: "span:han".into(),
995                    lane: "han".into(),
996                    start: -206,
997                    end: 220,
998                    label: "漢王朝".into(),
999                    tags: vec!["dynasty".into()],
1000                    source: None,
1001                    origin: None,
1002                    start_month: None,
1003                    start_day: None,
1004                    end_month: None,
1005                    end_day: None,
1006                    source_span: None,
1007                },
1008                Item::Event {
1009                    id: "event:1".into(),
1010                    lane: "han".into(),
1011                    time: 0,
1012                    label: "紀元".into(),
1013                    tags: vec![],
1014                    source: None,
1015                    origin: None,
1016                    time_month: None,
1017                    time_day: None,
1018                    source_span: None,
1019                },
1020            ],
1021            imports: vec![],
1022            sources: vec![],
1023        };
1024
1025        let table = generate_table_html(&ir, &ir.lanes);
1026        assert!(
1027            table.contains("<table class=\"tdsl-table\">"),
1028            "table must have tdsl-table class"
1029        );
1030        assert!(table.contains("<thead>"), "table must have thead");
1031        assert!(table.contains("<tbody>"), "table must have tbody");
1032        assert!(table.contains("漢王朝"), "table must contain span label");
1033        assert!(table.contains("紀元"), "table must contain event label");
1034        assert!(table.contains("漢"), "table must contain lane label");
1035        assert!(table.contains("dynasty"), "table must contain tags");
1036    }
1037
1038    #[test]
1039    fn generate_table_html_escapes_special_chars() {
1040        use tdsl_core::ir::{Item, Lane, Meta, TimelineIr};
1041
1042        let ir = TimelineIr {
1043            meta: Meta {
1044                title: "T".into(),
1045                unit: "year".into(),
1046                range: (0, 100),
1047                calendar: "proleptic_gregorian".into(),
1048                color_map: std::collections::HashMap::new(),
1049                ..Default::default()
1050            },
1051            lanes: vec![Lane {
1052                id: "l".into(),
1053                label: "Lane <A> & \"B\"".into(),
1054                kind: "k".into(),
1055                order: 1,
1056                group: None,
1057                source_span: None,
1058            }],
1059            items: vec![Item::Event {
1060                id: "e1".into(),
1061                lane: "l".into(),
1062                time: 50,
1063                label: "<script>alert('xss')</script>".into(),
1064                tags: vec!["<bad>".into()],
1065                source: None,
1066                origin: None,
1067                time_month: None,
1068                time_day: None,
1069                source_span: None,
1070            }],
1071            imports: vec![],
1072            sources: vec![],
1073        };
1074
1075        let table = generate_table_html(&ir, &ir.lanes);
1076        assert!(
1077            !table.contains("<script>"),
1078            "table must not contain raw <script> tag"
1079        );
1080        assert!(
1081            table.contains("&lt;script&gt;"),
1082            "label special chars must be escaped"
1083        );
1084        assert!(
1085            !table.contains("<bad>"),
1086            "tag special chars must be escaped"
1087        );
1088        assert!(
1089            table.contains("Lane &lt;A&gt; &amp; &quot;B&quot;"),
1090            "lane label special chars must be escaped"
1091        );
1092    }
1093
1094    #[test]
1095    fn generate_table_html_sort_order() {
1096        use tdsl_core::ir::{Item, Lane, Meta, TimelineIr};
1097
1098        let ir = TimelineIr {
1099            meta: Meta {
1100                title: "T".into(),
1101                unit: "year".into(),
1102                range: (0, 500),
1103                calendar: "proleptic_gregorian".into(),
1104                color_map: std::collections::HashMap::new(),
1105                ..Default::default()
1106            },
1107            lanes: vec![Lane {
1108                id: "l".into(),
1109                label: "L".into(),
1110                kind: "k".into(),
1111                order: 1,
1112                group: None,
1113                source_span: None,
1114            }],
1115            items: vec![
1116                Item::Event {
1117                    id: "e3".into(),
1118                    lane: "l".into(),
1119                    time: 300,
1120                    label: "C_event".into(),
1121                    tags: vec![],
1122                    source: None,
1123                    origin: None,
1124                    time_month: None,
1125                    time_day: None,
1126                    source_span: None,
1127                },
1128                Item::Span {
1129                    id: "s1".into(),
1130                    lane: "l".into(),
1131                    start: 100,
1132                    end: 200,
1133                    label: "A_span".into(),
1134                    tags: vec![],
1135                    source: None,
1136                    origin: None,
1137                    start_month: None,
1138                    start_day: None,
1139                    end_month: None,
1140                    end_day: None,
1141                    source_span: None,
1142                },
1143                Item::EventRange {
1144                    id: "er2".into(),
1145                    lane: "l".into(),
1146                    start: 200,
1147                    end: 250,
1148                    label: "B_event_range".into(),
1149                    tags: vec![],
1150                    source: None,
1151                    origin: None,
1152                    start_month: None,
1153                    start_day: None,
1154                    end_month: None,
1155                    end_day: None,
1156                    source_span: None,
1157                },
1158            ],
1159            imports: vec![],
1160            sources: vec![],
1161        };
1162
1163        let table = generate_table_html(&ir, &ir.lanes);
1164        let pos_a = table.find("A_span").expect("A_span must be in table");
1165        let pos_b = table
1166            .find("B_event_range")
1167            .expect("B_event_range must be in table");
1168        let pos_c = table.find("C_event").expect("C_event must be in table");
1169        assert!(
1170            pos_a < pos_b,
1171            "A_span (year 100) must appear before B_event_range (year 200)"
1172        );
1173        assert!(
1174            pos_b < pos_c,
1175            "B_event_range (year 200) must appear before C_event (year 300)"
1176        );
1177    }
1178}