Skip to main content

mecab_ko_core/
lattice_viz.rs

1//! Lattice 시각화 도구
2//!
3//! Lattice 구조를 다양한 형식으로 시각화합니다.
4//!
5//! # 지원 형식
6//!
7//! - **DOT (Graphviz)**: 그래프 시각화 도구용 출력
8//! - **HTML**: 인터랙티브 웹 뷰어
9//! - **텍스트**: 디버깅용 덤프
10//!
11//! # Example
12//!
13//! ```rust,no_run
14//! use mecab_ko_core::lattice::Lattice;
15//! use mecab_ko_core::lattice_viz::{LatticeViz, VizFormat};
16//!
17//! let lattice = Lattice::new("한국어");
18//! // ... 노드 추가 후 ...
19//!
20//! let viz = LatticeViz::new(&lattice);
21//!
22//! // DOT 형식 출력
23//! let dot = viz.to_dot();
24//! println!("{}", dot);
25//!
26//! // HTML 형식 출력
27//! let html = viz.to_html();
28//! std::fs::write("lattice.html", html).unwrap();
29//! ```
30
31use crate::lattice::{Lattice, Node, NodeType};
32use std::fmt::Write;
33
34/// 시각화 형식
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum VizFormat {
37    /// DOT/Graphviz 형식
38    #[default]
39    Dot,
40    /// HTML 인터랙티브 뷰어
41    Html,
42    /// 텍스트 덤프
43    Text,
44    /// JSON 형식
45    Json,
46}
47
48/// 시각화 옵션
49#[derive(Debug, Clone)]
50#[allow(clippy::struct_excessive_bools)]
51pub struct VizOptions {
52    /// 노드에 비용 표시
53    pub show_cost: bool,
54    /// 노드에 품사 표시
55    pub show_pos: bool,
56    /// 최적 경로 강조
57    pub highlight_best_path: bool,
58    /// 노드 타입별 색상 사용
59    pub use_colors: bool,
60    /// 최대 노드 수 (0이면 제한 없음)
61    pub max_nodes: usize,
62    /// 그래프 방향 (LR: 왼쪽→오른쪽, TB: 위→아래)
63    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    /// 새 옵션 생성
81    #[must_use]
82    pub fn new() -> Self {
83        Self::default()
84    }
85
86    /// 비용 표시 설정
87    #[must_use]
88    pub const fn with_cost(mut self, show: bool) -> Self {
89        self.show_cost = show;
90        self
91    }
92
93    /// 품사 표시 설정
94    #[must_use]
95    pub const fn with_pos(mut self, show: bool) -> Self {
96        self.show_pos = show;
97        self
98    }
99
100    /// 최적 경로 강조 설정
101    #[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    /// 색상 사용 설정
108    #[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    /// 최대 노드 수 설정
115    #[must_use]
116    pub const fn with_max_nodes(mut self, max: usize) -> Self {
117        self.max_nodes = max;
118        self
119    }
120
121    /// 그래프 방향 설정
122    #[must_use]
123    pub fn with_direction(mut self, dir: &str) -> Self {
124        self.direction = dir.to_string();
125        self
126    }
127}
128
129/// Lattice 시각화 도구
130pub struct LatticeViz<'a> {
131    lattice: &'a Lattice,
132    options: VizOptions,
133    best_path_ids: Vec<u32>,
134}
135
136impl<'a> LatticeViz<'a> {
137    /// 새 시각화 도구 생성
138    #[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    /// 옵션 설정
149    #[must_use]
150    pub fn with_options(mut self, options: VizOptions) -> Self {
151        self.options = options;
152        self
153    }
154
155    /// DOT 형식으로 변환
156    #[must_use]
157    pub fn to_dot(&self) -> String {
158        let mut output = String::new();
159
160        // 그래프 헤더
161        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        // 노드 정의
169        self.write_dot_nodes(&mut output);
170
171        // 엣지 정의
172        self.write_dot_edges(&mut output);
173
174        writeln!(output, "}}").ok();
175        output
176    }
177
178    /// DOT 노드 정의 작성
179    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            // 노드 스타일
184            let (color, style) = self.get_node_style(node, is_on_best_path);
185
186            // 노드 라벨
187            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    /// DOT 엣지 정의 작성
199    fn write_dot_edges(&self, output: &mut String) {
200        let char_len = self.lattice.char_len();
201
202        // 각 위치에서 끝나는 노드 → 시작하는 노드 연결
203        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    /// 노드 스타일 결정
236    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    /// 노드 라벨 생성
262    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        // DOT 특수문자 이스케이프
279        label.replace('"', "\\\"")
280    }
281
282    /// HTML 형식으로 변환
283    #[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    /// 텍스트 덤프 형식으로 변환
398    #[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        // 노드 목록
410        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        // 위치별 노드
452        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        // 최적 경로
478        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    /// JSON 형식으로 변환
494    #[must_use]
495    pub fn to_json(&self) -> String {
496        let mut nodes = Vec::new();
497        let mut edges = Vec::new();
498
499        // 노드 정보 수집
500        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        // 엣지 정보 수집
541        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    /// 지정된 형식으로 변환
580    #[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/// 편의 함수: Lattice를 DOT 형식으로 변환
592#[must_use]
593pub fn lattice_to_dot(lattice: &Lattice) -> String {
594    LatticeViz::new(lattice).to_dot()
595}
596
597/// 편의 함수: Lattice를 HTML 형식으로 변환
598#[must_use]
599pub fn lattice_to_html(lattice: &Lattice) -> String {
600    LatticeViz::new(lattice).to_html()
601}
602
603/// 편의 함수: Lattice를 텍스트 덤프로 변환
604#[must_use]
605pub fn lattice_to_text(lattice: &Lattice) -> String {
606    LatticeViz::new(lattice).to_text()
607}
608
609/// 편의 함수: Lattice를 JSON 형식으로 변환
610#[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}