1pub 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
162const 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
187const 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
220const 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("&"),
321 '<' => out.push_str("<"),
322 '>' => out.push_str(">"),
323 '"' => out.push_str("""),
324 '\'' => out.push_str("'"),
325 _ => out.push(c),
326 }
327 }
328 out
329}
330
331const TABLE_COL_TIME: &str = "時期";
333const TABLE_COL_LABEL: &str = "ラベル";
334const TABLE_COL_LANE: &str = "レーン";
335const TABLE_COL_TAGS: &str = "タグ";
336
337struct TableRow {
339 sort_year: i64,
341 sort_type: u8,
343 time_str: String,
345 label: String,
346 lane_label: String,
347 tags: String,
348}
349
350pub(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
472pub 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 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="閉じる">×</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, "&")
856 .replace(/</g, "<")
857 .replace(/>/g, ">")
858 .replace(/"/g, """)
859 .replace(/'/g, "'");
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 & B <danger>"));
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 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 assert!(
936 html.contains("fef9f0"),
937 "pastel theme should include #fef9f0 background"
938 );
939 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("<script>"),
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 <A> & "B""),
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}