1use crate::lattice::{Lattice, Node, NodeType};
32use std::fmt::Write;
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum VizFormat {
37 #[default]
39 Dot,
40 Html,
42 Text,
44 Json,
46}
47
48#[derive(Debug, Clone)]
50#[allow(clippy::struct_excessive_bools)]
51pub struct VizOptions {
52 pub show_cost: bool,
54 pub show_pos: bool,
56 pub highlight_best_path: bool,
58 pub use_colors: bool,
60 pub max_nodes: usize,
62 pub direction: String,
64}
65
66impl Default for VizOptions {
67 fn default() -> Self {
68 Self {
69 show_cost: true,
70 show_pos: true,
71 highlight_best_path: true,
72 use_colors: true,
73 max_nodes: 0,
74 direction: "LR".to_string(),
75 }
76 }
77}
78
79impl VizOptions {
80 #[must_use]
82 pub fn new() -> Self {
83 Self::default()
84 }
85
86 #[must_use]
88 pub const fn with_cost(mut self, show: bool) -> Self {
89 self.show_cost = show;
90 self
91 }
92
93 #[must_use]
95 pub const fn with_pos(mut self, show: bool) -> Self {
96 self.show_pos = show;
97 self
98 }
99
100 #[must_use]
102 pub const fn with_best_path(mut self, highlight: bool) -> Self {
103 self.highlight_best_path = highlight;
104 self
105 }
106
107 #[must_use]
109 pub const fn with_colors(mut self, use_colors: bool) -> Self {
110 self.use_colors = use_colors;
111 self
112 }
113
114 #[must_use]
116 pub const fn with_max_nodes(mut self, max: usize) -> Self {
117 self.max_nodes = max;
118 self
119 }
120
121 #[must_use]
123 pub fn with_direction(mut self, dir: &str) -> Self {
124 self.direction = dir.to_string();
125 self
126 }
127}
128
129pub struct LatticeViz<'a> {
131 lattice: &'a Lattice,
132 options: VizOptions,
133 best_path_ids: Vec<u32>,
134}
135
136impl<'a> LatticeViz<'a> {
137 #[must_use]
139 pub fn new(lattice: &'a Lattice) -> Self {
140 let best_path_ids = lattice.best_path().iter().map(|n| n.id).collect();
141 Self {
142 lattice,
143 options: VizOptions::default(),
144 best_path_ids,
145 }
146 }
147
148 #[must_use]
150 pub fn with_options(mut self, options: VizOptions) -> Self {
151 self.options = options;
152 self
153 }
154
155 #[must_use]
157 pub fn to_dot(&self) -> String {
158 let mut output = String::new();
159
160 writeln!(
162 output,
163 "digraph Lattice {{\n rankdir={};\n node [shape=box, fontname=\"Noto Sans KR\"];",
164 self.options.direction
165 )
166 .ok();
167
168 self.write_dot_nodes(&mut output);
170
171 self.write_dot_edges(&mut output);
173
174 writeln!(output, "}}").ok();
175 output
176 }
177
178 fn write_dot_nodes(&self, output: &mut String) {
180 for node in self.lattice.nodes() {
181 let is_on_best_path = self.best_path_ids.contains(&node.id);
182
183 let (color, style) = self.get_node_style(node, is_on_best_path);
185
186 let label = self.get_node_label(node);
188
189 writeln!(
190 output,
191 " n{} [label=\"{}\", fillcolor=\"{}\", style=\"{}\"];",
192 node.id, label, color, style
193 )
194 .ok();
195 }
196 }
197
198 fn write_dot_edges(&self, output: &mut String) {
200 let char_len = self.lattice.char_len();
201
202 for pos in 0..=char_len {
204 let ending_nodes: Vec<_> = self.lattice.nodes_ending_at(pos).collect();
205 let starting_nodes: Vec<_> = self.lattice.nodes_starting_at(pos).collect();
206
207 for end_node in &ending_nodes {
208 for start_node in &starting_nodes {
209 let is_best = self.best_path_ids.contains(&end_node.id)
210 && self.best_path_ids.contains(&start_node.id);
211
212 let style = if is_best && self.options.highlight_best_path {
213 "bold"
214 } else {
215 "solid"
216 };
217
218 let color = if is_best && self.options.highlight_best_path {
219 "red"
220 } else {
221 "black"
222 };
223
224 writeln!(
225 output,
226 " n{} -> n{} [style={}, color={}];",
227 end_node.id, start_node.id, style, color
228 )
229 .ok();
230 }
231 }
232 }
233 }
234
235 const fn get_node_style(
237 &self,
238 node: &Node,
239 is_on_best_path: bool,
240 ) -> (&'static str, &'static str) {
241 if !self.options.use_colors {
242 return ("white", "solid");
243 }
244
245 let base_color = match node.node_type {
246 NodeType::Bos | NodeType::Eos => "#e0e0e0",
247 NodeType::Known => "#d4edda",
248 NodeType::Unknown => "#f8d7da",
249 NodeType::User => "#d1ecf1",
250 };
251
252 let style = if is_on_best_path && self.options.highlight_best_path {
253 "filled,bold"
254 } else {
255 "filled"
256 };
257
258 (base_color, style)
259 }
260
261 fn get_node_label(&self, node: &Node) -> String {
263 let mut label = node.surface.to_string();
264
265 if self.options.show_pos && !matches!(node.node_type, NodeType::Bos | NodeType::Eos) {
266 let pos = node.feature.split(',').next().unwrap_or("*");
267 write!(label, "\\n{pos}").ok();
268 }
269
270 if self.options.show_cost {
271 if node.total_cost == i32::MAX {
272 write!(label, "\\n[∞]").ok();
273 } else {
274 write!(label, "\\n[{}]", node.total_cost).ok();
275 }
276 }
277
278 label.replace('"', "\\\"")
280 }
281
282 #[must_use]
284 #[allow(clippy::too_many_lines)]
285 pub fn to_html(&self) -> String {
286 let dot = self.to_dot();
287 let text = self.lattice.text();
288 let graph_selector = "#lattice-graph";
289
290 format!(
291 r#"<!DOCTYPE html>
292<html lang="ko">
293<head>
294 <meta charset="UTF-8">
295 <meta name="viewport" content="width=device-width, initial-scale=1.0">
296 <title>Lattice Visualization: {text}</title>
297 <script src="https://d3js.org/d3.v7.min.js"></script>
298 <script src="https://unpkg.com/@hpcc-js/wasm/dist/graphviz.umd.js"></script>
299 <script src="https://unpkg.com/d3-graphviz@5.1.0/build/d3-graphviz.js"></script>
300 <style>
301 body {{
302 font-family: 'Noto Sans KR', sans-serif;
303 margin: 0;
304 padding: 20px;
305 background-color: #f5f5f5;
306 }}
307 .container {{
308 max-width: 100%;
309 background: white;
310 padding: 20px;
311 border-radius: 8px;
312 box-shadow: 0 2px 4px rgba(0,0,0,0.1);
313 }}
314 h1 {{
315 color: #333;
316 margin-top: 0;
317 }}
318 .info {{
319 background: #f8f9fa;
320 padding: 10px;
321 border-radius: 4px;
322 margin-bottom: 20px;
323 }}
324 .info span {{
325 margin-right: 20px;
326 }}
327 .graph-container {{
328 overflow: auto;
329 border: 1px solid #ddd;
330 border-radius: 4px;
331 background: white;
332 }}
333 .legend {{
334 margin-top: 20px;
335 display: flex;
336 gap: 20px;
337 flex-wrap: wrap;
338 }}
339 .legend-item {{
340 display: flex;
341 align-items: center;
342 gap: 5px;
343 }}
344 .legend-color {{
345 width: 20px;
346 height: 20px;
347 border-radius: 4px;
348 border: 1px solid #ccc;
349 }}
350 </style>
351</head>
352<body>
353 <div class="container">
354 <h1>Lattice Visualization</h1>
355 <div class="info">
356 <span><strong>입력:</strong> {text}</span>
357 <span><strong>노드 수:</strong> {node_count}</span>
358 <span><strong>문자 수:</strong> {char_len}</span>
359 </div>
360 <div id="lattice-graph" class="graph-container"></div>
361 <div class="legend">
362 <div class="legend-item">
363 <div class="legend-color" style="background: #d4edda;"></div>
364 <span>Known (사전)</span>
365 </div>
366 <div class="legend-item">
367 <div class="legend-color" style="background: #f8d7da;"></div>
368 <span>Unknown (미등록)</span>
369 </div>
370 <div class="legend-item">
371 <div class="legend-color" style="background: #d1ecf1;"></div>
372 <span>User (사용자)</span>
373 </div>
374 <div class="legend-item">
375 <div class="legend-color" style="background: #e0e0e0;"></div>
376 <span>BOS/EOS</span>
377 </div>
378 </div>
379 </div>
380 <script>
381 const dot = `{dot_escaped}`;
382 d3.select("{graph_selector}").graphviz()
383 .zoom(true)
384 .fit(true)
385 .renderDot(dot);
386 </script>
387</body>
388</html>"#,
389 text = text,
390 node_count = self.lattice.node_count(),
391 char_len = self.lattice.char_len(),
392 dot_escaped = dot.replace('`', "\\`").replace("${", "\\${"),
393 graph_selector = graph_selector
394 )
395 }
396
397 #[must_use]
399 pub fn to_text(&self) -> String {
400 let mut output = String::new();
401
402 writeln!(output, "=== Lattice Dump ===").ok();
403 writeln!(output, "Text: \"{}\"", self.lattice.text()).ok();
404 writeln!(output, "Original: \"{}\"", self.lattice.original_text()).ok();
405 writeln!(output, "Char length: {}", self.lattice.char_len()).ok();
406 writeln!(output, "Node count: {}", self.lattice.node_count()).ok();
407 writeln!(output).ok();
408
409 writeln!(output, "--- Nodes ---").ok();
411 for node in self.lattice.nodes() {
412 let type_str = match node.node_type {
413 NodeType::Bos => "BOS",
414 NodeType::Eos => "EOS",
415 NodeType::Known => "KNW",
416 NodeType::Unknown => "UNK",
417 NodeType::User => "USR",
418 };
419
420 let is_best = if self.best_path_ids.contains(&node.id) {
421 " *"
422 } else {
423 ""
424 };
425
426 writeln!(
427 output,
428 "[{:3}] {} {:>4} {:<10} ({:2}-{:2}) cost={:6} total={:10} prev={:3}{}",
429 node.id,
430 type_str,
431 node.feature.split(',').next().unwrap_or("*"),
432 node.surface,
433 node.start_pos,
434 node.end_pos,
435 node.word_cost,
436 if node.total_cost == i32::MAX {
437 "∞".to_string()
438 } else {
439 node.total_cost.to_string()
440 },
441 if node.prev_node_id == u32::MAX {
442 "-".to_string()
443 } else {
444 node.prev_node_id.to_string()
445 },
446 is_best
447 )
448 .ok();
449 }
450
451 writeln!(output).ok();
453 writeln!(output, "--- By Position ---").ok();
454 for pos in 0..=self.lattice.char_len() {
455 let ending: Vec<_> = self.lattice.nodes_ending_at(pos).collect();
456 let starting: Vec<_> = self.lattice.nodes_starting_at(pos).collect();
457
458 if !ending.is_empty() || !starting.is_empty() {
459 writeln!(output, "Position {pos}:").ok();
460 if !ending.is_empty() {
461 let names: Vec<_> = ending
462 .iter()
463 .map(|n| format!("{}({})", n.surface, n.id))
464 .collect();
465 writeln!(output, " Ending: {}", names.join(", ")).ok();
466 }
467 if !starting.is_empty() {
468 let names: Vec<_> = starting
469 .iter()
470 .map(|n| format!("{}({})", n.surface, n.id))
471 .collect();
472 writeln!(output, " Starting: {}", names.join(", ")).ok();
473 }
474 }
475 }
476
477 if !self.best_path_ids.is_empty() {
479 writeln!(output).ok();
480 writeln!(output, "--- Best Path ---").ok();
481 let path: Vec<_> = self
482 .lattice
483 .best_path()
484 .iter()
485 .map(|n| format!("{}", n.surface))
486 .collect();
487 writeln!(output, "{}", path.join(" → ")).ok();
488 }
489
490 output
491 }
492
493 #[must_use]
495 pub fn to_json(&self) -> String {
496 let mut nodes = Vec::new();
497 let mut edges = Vec::new();
498
499 for node in self.lattice.nodes() {
501 let node_type = match node.node_type {
502 NodeType::Bos => "bos",
503 NodeType::Eos => "eos",
504 NodeType::Known => "known",
505 NodeType::Unknown => "unknown",
506 NodeType::User => "user",
507 };
508
509 let pos = node.feature.split(',').next().unwrap_or("*");
510 let is_best = self.best_path_ids.contains(&node.id);
511
512 nodes.push(format!(
513 r#" {{
514 "id": {},
515 "surface": "{}",
516 "pos": "{}",
517 "type": "{}",
518 "start": {},
519 "end": {},
520 "wordCost": {},
521 "totalCost": {},
522 "isBestPath": {}
523 }}"#,
524 node.id,
525 node.surface.replace('"', "\\\""),
526 pos,
527 node_type,
528 node.start_pos,
529 node.end_pos,
530 node.word_cost,
531 if node.total_cost == i32::MAX {
532 "null".to_string()
533 } else {
534 node.total_cost.to_string()
535 },
536 is_best
537 ));
538 }
539
540 let char_len = self.lattice.char_len();
542 for pos in 0..=char_len {
543 let ending_nodes: Vec<_> = self.lattice.nodes_ending_at(pos).collect();
544 let starting_nodes: Vec<_> = self.lattice.nodes_starting_at(pos).collect();
545
546 for end_node in &ending_nodes {
547 for start_node in &starting_nodes {
548 let is_best = self.best_path_ids.contains(&end_node.id)
549 && self.best_path_ids.contains(&start_node.id);
550
551 edges.push(format!(
552 r#" {{"from": {}, "to": {}, "isBestPath": {}}}"#,
553 end_node.id, start_node.id, is_best
554 ));
555 }
556 }
557 }
558
559 format!(
560 r#"{{
561 "text": "{}",
562 "originalText": "{}",
563 "charLength": {},
564 "nodes": [
565{}
566 ],
567 "edges": [
568{}
569 ]
570}}"#,
571 self.lattice.text().replace('"', "\\\""),
572 self.lattice.original_text().replace('"', "\\\""),
573 self.lattice.char_len(),
574 nodes.join(",\n"),
575 edges.join(",\n")
576 )
577 }
578
579 #[must_use]
581 pub fn format(&self, fmt: VizFormat) -> String {
582 match fmt {
583 VizFormat::Dot => self.to_dot(),
584 VizFormat::Html => self.to_html(),
585 VizFormat::Text => self.to_text(),
586 VizFormat::Json => self.to_json(),
587 }
588 }
589}
590
591#[must_use]
593pub fn lattice_to_dot(lattice: &Lattice) -> String {
594 LatticeViz::new(lattice).to_dot()
595}
596
597#[must_use]
599pub fn lattice_to_html(lattice: &Lattice) -> String {
600 LatticeViz::new(lattice).to_html()
601}
602
603#[must_use]
605pub fn lattice_to_text(lattice: &Lattice) -> String {
606 LatticeViz::new(lattice).to_text()
607}
608
609#[must_use]
611pub fn lattice_to_json(lattice: &Lattice) -> String {
612 LatticeViz::new(lattice).to_json()
613}
614
615#[cfg(test)]
616mod tests {
617 use super::*;
618 use crate::lattice::NodeBuilder;
619
620 fn create_test_lattice() -> Lattice {
621 let mut lattice = Lattice::new("안녕");
622
623 lattice.add_node(
624 NodeBuilder::new("안녕", 0, 2)
625 .left_id(1)
626 .right_id(1)
627 .word_cost(1000)
628 .feature("NNG,*,F,안녕,*,*,*,*"),
629 );
630
631 lattice.add_node(
632 NodeBuilder::new("안", 0, 1)
633 .left_id(2)
634 .right_id(2)
635 .word_cost(2000)
636 .feature("NNG,*,T,안,*,*,*,*"),
637 );
638
639 lattice.add_node(
640 NodeBuilder::new("녕", 1, 2)
641 .left_id(3)
642 .right_id(3)
643 .word_cost(3000)
644 .feature("NNG,*,T,녕,*,*,*,*"),
645 );
646
647 lattice
648 }
649
650 #[test]
651 fn test_to_dot() {
652 let lattice = create_test_lattice();
653 let viz = LatticeViz::new(&lattice);
654 let dot = viz.to_dot();
655
656 assert!(dot.contains("digraph Lattice"));
657 assert!(dot.contains("안녕"));
658 assert!(dot.contains("->"));
659 }
660
661 #[test]
662 fn test_to_text() {
663 let lattice = create_test_lattice();
664 let viz = LatticeViz::new(&lattice);
665 let text = viz.to_text();
666
667 assert!(text.contains("Lattice Dump"));
668 assert!(text.contains("안녕"));
669 assert!(text.contains("By Position"));
670 }
671
672 #[test]
673 fn test_to_json() {
674 let lattice = create_test_lattice();
675 let viz = LatticeViz::new(&lattice);
676 let json = viz.to_json();
677
678 assert!(json.contains("\"text\": \"안녕\""));
679 assert!(json.contains("\"nodes\""));
680 assert!(json.contains("\"edges\""));
681 }
682
683 #[test]
684 fn test_to_html() {
685 let lattice = create_test_lattice();
686 let viz = LatticeViz::new(&lattice);
687 let html = viz.to_html();
688
689 assert!(html.contains("<!DOCTYPE html>"));
690 assert!(html.contains("Lattice Visualization"));
691 assert!(html.contains("d3-graphviz"));
692 }
693
694 #[test]
695 fn test_viz_options() {
696 let options = VizOptions::new()
697 .with_cost(false)
698 .with_pos(false)
699 .with_best_path(false)
700 .with_colors(false);
701
702 assert!(!options.show_cost);
703 assert!(!options.show_pos);
704 assert!(!options.highlight_best_path);
705 assert!(!options.use_colors);
706 }
707
708 #[test]
709 fn test_format_selection() {
710 let lattice = create_test_lattice();
711 let viz = LatticeViz::new(&lattice);
712
713 let dot = viz.format(VizFormat::Dot);
714 let text = viz.format(VizFormat::Text);
715 let json = viz.format(VizFormat::Json);
716 let html = viz.format(VizFormat::Html);
717
718 assert!(dot.contains("digraph"));
719 assert!(text.contains("Lattice Dump"));
720 assert!(json.contains("\"nodes\""));
721 assert!(html.contains("<!DOCTYPE html>"));
722 }
723}