/
e4779
/
okf-viewer
Обзор
Документация
Войти
/
e4779
/
okf-viewer
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
src/html.rs
333 строки
12 KB
Ерм Ник
feat: initial release — okf-viewer v0.1.0
05 июл 2026, 18:03
05 июл 2026, 18:03
664d11a
Код
Авторство
О чём код?
//! Render the final self-contained HTML page. use serde_json::to_string_pretty; use crate::graph::BundleData; const CYTOSCAPE: &str = include_str!("../vendor/cytoscape.min.js"); const MARKED: &str = include_str!("../vendor/marked.min.js"); /// Render the full HTML. pub fn render(data: &BundleData) -> String { // Serialize data with serde_json to avoid hand-rolling JS literals. let data_json = to_string_pretty(data).unwrap_or_else(|_| " {}".into()); let palette_json = palette_json(&data.stats.types); let title = format!("OKF bundle: {}", data.name); format!( r#"<!doctype html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{title}</title> <style> * {{ box-sizing: border-box; }} html, body {{ margin: 0; padding: 0; height: 100%; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; background: #0f1115; color: #e6e6e6; }} #stats {{ position: fixed; top: 0; left: 0; right: 0; z-index: 10; padding: 8px 14px; background: #161a21; border-bottom: 1px solid #232831; font-size: 13px; line-height: 1.4; }} #stats .types {{ color: #8b95a7; }} #search {{ position: fixed; top: 36px; left: 14px; z-index: 10; width: 280px; padding: 6px 10px; background: #1c2230; border: 1px solid #2c3444; color: #e6e6e6; border-radius: 4px; font-size: 13px; }} #search:focus {{ outline: none; border-color: #4a90e2; }} #legend {{ position: fixed; top: 36px; right: 14px; z-index: 10; background: #161a21; border: 1px solid #232831; border-radius: 4px; padding: 8px 10px; font-size: 12px; max-height: 60vh; overflow-y: auto; }} #legend h4 {{ margin: 0 0 6px 0; font-size: 12px; color: #8b95a7; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }} #legend .item {{ display: flex; align-items: center; gap: 6px; margin: 2px 0; }} #legend .swatch {{ width: 12px; height: 12px; border-radius: 50%; display: inline-block; }} #cy {{ position: absolute; top: 0; left: 0; right: 0; bottom: 0; }} #concept-panel {{ position: fixed; right: 0; top: 70px; bottom: 0; width: 520px; max-width: 50vw; background: #161a21; border-left: 1px solid #232831; padding: 18px 22px; overflow-y: auto; transform: translateX(110%); transition: transform 0.18s ease; box-shadow: -8px 0 24px rgba(0,0,0,0.4); }} #concept-panel.open {{ transform: translateX(0); }} #concept-panel header {{ border-bottom: 1px solid #232831; padding-bottom: 10px; margin-bottom: 12px; }} #concept-panel h2 {{ margin: 0 0 6px 0; font-size: 18px; color: #fff; }} #concept-panel #cp-meta {{ margin: 0; font-size: 12px; color: #8b95a7; }} #concept-panel #cp-meta .tag {{ display: inline-block; background: #232831; color: #c0c8d6; padding: 1px 6px; border-radius: 3px; margin: 2px 4px 0 0; font-size: 11px; }} #concept-panel #cp-meta .ctype {{ color: #4a90e2; font-weight: 600; }} #concept-panel #cp-body {{ font-size: 14px; line-height: 1.55; color: #d6dae2; word-wrap: break-word; }} #concept-panel #cp-body h1,#concept-panel #cp-body h2,#concept-panel #cp-body h3 {{ color: #fff; margin-top: 18px; }} #concept-panel #cp-body a {{ color: #4a90e2; }} #concept-panel #cp-body code {{ background: #232831; padding: 1px 4px; border-radius: 3px; font-size: 12px; }} #concept-panel #cp-body pre {{ background: #0f1115; padding: 10px; border-radius: 4px; overflow-x: auto; }} #concept-panel #cp-body pre code {{ background: transparent; padding: 0; }} #concept-panel #cp-body table {{ border-collapse: collapse; }} #concept-panel #cp-body td,#concept-panel #cp-body th {{ border: 1px solid #2c3444; padding: 4px 8px; }} #close-panel {{ position: absolute; top: 12px; right: 14px; cursor: pointer; color: #8b95a7; font-size: 18px; background: none; border: none; }} #close-panel:hover {{ color: #fff; }} </style> </head> <body> <div id="stats"> <strong id="stat-count"></strong> concepts · <strong id="stat-links"></strong> links · <strong id="stat-broken"></strong> broken · <span class="types" id="stat-types"></span> </div> <input id="search" placeholder="filter by title, tag, or id…" autocomplete="off"/> <div id="legend"><h4>Types</h4><div id="legend-items"></div></div> <div id="cy"></div> <aside id="concept-panel"> <button id="close-panel" title="close">×</button> <header> <h2 id="cp-title"></h2> <p id="cp-meta"></p> </header> <div id="cp-body"></div> </aside> <script> // ===== vendored cytoscape.js ===== {cytoscape} // ===== vendored marked.js ===== {marked} // ===== bundle data ===== window.BUNDLE = {data_json}; // ===== type palette ===== window.PALETTE = {palette_json}; // object: type -> color (function() {{ const B = window.BUNDLE; const pal = window.PALETTE; // object: type -> color // stats document.getElementById('stat-count').textContent = B.stats.concepts; document.getElementById('stat-links').textContent = B.stats.links; document.getElementById('stat-broken').textContent = B.stats.broken; document.getElementById('stat-types').textContent = 'types: ' + B.stats.types.map(([t,n]) => t + ' ' + n).join(', '); // legend const legendEl = document.getElementById('legend-items'); B.stats.types.forEach(([t, n]) => {{ const row = document.createElement('div'); row.className = 'item'; const sw = document.createElement('span'); sw.className = 'swatch'; sw.style.background = pal[t] || '#888'; const lab = document.createElement('span'); lab.textContent = t + ' (' + n + ')'; row.appendChild(sw); row.appendChild(lab); legendEl.appendChild(row); }}); // build cytoscape elements const maxInbound = B.nodes.reduce((m, n) => Math.max(m, n.inboundCount), 0) || 1; const elements = []; const nodeById = {{}}; B.nodes.forEach(n => {{ nodeById[n.id] = n; const size = 18 + 26 * (n.inboundCount / maxInbound); elements.push({{ group: 'nodes', data: {{ id: n.id, label: n.title || n.id, ctype: n.type, size: size, color: pal[n.type] || '#888', concept: n, }} }}); }}); // also create phantom nodes for broken targets so edges render const brokenNodeIds = new Set(); B.edges.forEach(e => {{ if (!nodeById[e.target] && !brokenNodeIds.has(e.target)) {{ brokenNodeIds.add(e.target); elements.push({{ group: 'nodes', data: {{ id: e.target, label: e.target + ' ?', ctype: '__broken__', size: 14, color: '#c0392b', concept: null, }} }}); }} }}); B.edges.forEach((e, i) => {{ elements.push({{ group: 'edges', data: {{ id: 'e' + i, source: e.source, target: e.target, broken: e.broken, }} }}); }}); const cy = cytoscape({{ container: document.getElementById('cy'), elements: elements, style: [ {{ selector: 'node', style: {{ 'background-color': 'data(color)', 'width': 'data(size)', 'height': 'data(size)', 'label': 'data(label)', 'font-size': '9px', 'color': '#c0c8d6', 'text-valign': 'bottom', 'text-halign': 'center', 'text-outline-color': '#0f1115', 'text-outline-width': '2px', 'border-width': '1px', 'border-color': '#2c3444', }} }}, {{ selector: 'node:selected', style: {{ 'border-width': '3px', 'border-color': '#fff' }} }}, {{ selector: 'edge', style: {{ 'width': 1, 'line-color': '#5a6577', 'target-arrow-color': '#5a6577', 'target-arrow-shape': 'triangle', 'curve-style': 'bezier', 'opacity': 0.55, 'arrow-scale': 0.7, }} }}, {{ selector: 'edge[broken = true]', style: {{ 'line-color': '#c0392b', 'target-arrow-color': '#c0392b', 'line-style': 'dashed', 'opacity': 0.7, }} }}, ], layout: {{ name: 'cose', animate: false, nodeRepulsion: 9000, idealEdgeLength: 90, nodeOverlap: 12, padding: 10, }}, }}); // Side panel. const panel = document.getElementById('concept-panel'); const cpTitle = document.getElementById('cp-title'); const cpMeta = document.getElementById('cp-meta'); const cpBody = document.getElementById('cp-body'); const PREVIEW_LIMIT = 5000; function showConcept(n) {{ cpTitle.textContent = n.title || n.id; const tagsHtml = (n.tags || []).map(t => '<span class="tag">' + escapeHtml(t) + '</span>').join(''); cpMeta.innerHTML = '<span class="ctype">' + escapeHtml(n.type) + '</span> · ' + escapeHtml(n.id) + (n.description ? ' · ' + escapeHtml(n.description) : '') + '<br>' + tagsHtml; const bodyText = (n.body || '').length > PREVIEW_LIMIT ? (n.body.substring(0, PREVIEW_LIMIT) + '\n\n…_(truncated; full body in window.BUNDLE)_') : (n.body || ''); cpBody.innerHTML = window.marked.parse(bodyText); panel.classList.add('open'); }} function escapeHtml(s) {{ return String(s).replace(/[&<>"']/g, c => ({{'&':'&','<':'<','>':'>','"':'"',"'":'''}})[c]); }} window.cy = cy; cy.on('tap', 'node', function(evt) {{ const data = evt.target.data('concept'); if (data) showConcept(data); }}); document.getElementById('close-panel').addEventListener('click', () => {{ panel.classList.remove('open'); }}); // Search filter. const search = document.getElementById('search'); search.addEventListener('input', () => {{ const q = search.value.trim().toLowerCase(); if (!q) {{ cy.elements().style('display', 'element'); return; }} const matched = new Set(); cy.nodes().forEach(node => {{ const c = node.data('concept'); let hay = node.id().toLowerCase(); if (c) {{ hay += ' ' + (c.title || '').toLowerCase(); hay += ' ' + (c.type || '').toLowerCase(); hay += ' ' + (c.tags || []).join(' ').toLowerCase(); }} if (hay.includes(q)) {{ node.style('display', 'element'); matched.add(node.id()); }} else {{ node.style('display', 'none'); }} }}); // edges: show only if both ends visible cy.edges().forEach(e => {{ const ok = matched.has(e.data('source')) && matched.has(e.data('target')); e.style('display', ok ? 'element' : 'none'); }}); }}); }})(); </script> </body> </html> "#, title = html_escape(&title), cytoscape = CYTOSCAPE, marked = MARKED, data_json = data_json, palette_json = palette_json, ) } /// Deterministic color palette — 10 distinguishable colors. const COLORS: &[&str] = &[ "#4a90e2", // blue "#7cb342", // green "#f5a623", // orange "#bd10e0", // purple "#d0021b", // red "#50e3c2", // teal "#f8e71c", // yellow "#9b9b9b", // gray "#e91e63", // pink "#8b4513", // brown ]; /// Build a JSON object `{type -> color}`. Types are taken in alphabetical /// order (already sorted by graph::build) and assigned palette colors in turn. fn palette_json(types: &[(String, usize)]) -> String { let mut s = String::from("{ "); for (i, (t, _)) in types.iter().enumerate() { if i > 0 { s.push_str(", "); } s.push_str(&format!( "{}: {}", serde_json::to_string(t).unwrap(), serde_json::to_string(COLORS[i % COLORS.len()]).unwrap() )); } s.push_str(" }"); s } fn html_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") }