narty-2027/viz.html

568 lines
81 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>OKF Bundle Viewer</title>
<script src="https://cdn.jsdelivr.net/npm/cytoscape@3.28.1/dist/cytoscape.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@12.0.0/marked.min.js"></script>
<style>
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
font-size: 14px;
color: #0f172a;
background: #f8fafc;
display: flex;
flex-direction: column;
height: 100vh;
}
header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 16px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
flex-shrink: 0;
}
.title strong { font-size: 16px; margin-right: 8px; }
.muted { color: #64748b; font-size: 12px; }
.controls { display: flex; gap: 8px; }
.controls input, .controls select, .controls button {
font-size: 13px;
padding: 5px 8px;
border: 1px solid #cbd5e1;
border-radius: 4px;
background: #fff;
}
.controls input { width: 220px; }
.controls button { cursor: pointer; background: #f1f5f9; }
.controls button:hover { background: #e2e8f0; }
main {
display: flex;
flex: 1;
min-height: 0;
}
#graph {
flex: 1 1 60%;
background: #fff;
border-right: 1px solid #e2e8f0;
min-width: 0;
position: relative;
}
#detail {
flex: 0 0 40%;
overflow-y: auto;
padding: 18px 22px;
background: #fff;
}
#detail-empty {
text-align: center;
margin-top: 40px;
}
.detail-header { margin-bottom: 12px; }
.detail-header h1 {
font-size: 18px;
margin: 4px 0 2px;
font-weight: 600;
}
.type-chip {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
color: #fff;
background: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
dl.frontmatter {
display: grid;
grid-template-columns: 90px 1fr;
row-gap: 4px;
column-gap: 12px;
margin: 8px 0 12px;
font-size: 13px;
}
dl.frontmatter dt {
color: #64748b;
font-weight: 500;
}
dl.frontmatter dd { margin: 0; }
dl.frontmatter a { color: #2563eb; word-break: break-all; }
.tag {
display: inline-block;
padding: 1px 6px;
margin: 0 4px 2px 0;
border-radius: 4px;
background: #f1f5f9;
color: #475569;
font-size: 11px;
}
.badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 8px 0 4px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
border: 1px solid transparent;
}
.badge.status-stable { background: #ecfdf5; color: #047857; border-color: #a7f3d0; }
.badge.status-draft { background: #fefce8; color: #a16207; border-color: #fde68a; }
.badge.status-deprecated { background: #f1f5f9; color: #64748b; border-color: #cbd5e1; text-decoration: line-through; }
.badge.trust-unverified { background: #f1f5f9; color: #64748b; border-color: #cbd5e1; }
.badge.trust-machine-confirmed { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
.badge.trust-human-reviewed { background: #f5f3ff; color: #6d28d9; border-color: #ddd6fe; }
.badge.stale { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
.badge.fresh { background: #ecfdf5; color: #047857; border-color: #a7f3d0; }
.sources-list { padding-left: 18px; margin: 0; }
.sources-list li { margin: 1px 0; }
hr { border: none; border-top: 1px solid #e2e8f0; margin: 14px 0; }
#detail-body { font-size: 13px; line-height: 1.55; }
#detail-body h1 {
font-size: 16px; margin: 18px 0 6px;
padding-bottom: 4px; border-bottom: 1px solid #e2e8f0;
}
#detail-body h2 { font-size: 14px; margin: 14px 0 4px; }
#detail-body h3 { font-size: 13px; margin: 12px 0 4px; }
#detail-body p { margin: 6px 0; }
#detail-body code {
background: #f1f5f9;
padding: 1px 4px;
border-radius: 3px;
font-size: 12px;
font-family: ui-monospace, "SF Mono", Consolas, monospace;
}
#detail-body pre {
background: #0f172a;
color: #e2e8f0;
padding: 10px 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 12px;
}
#detail-body pre code { background: transparent; color: inherit; padding: 0; }
#detail-body ul, #detail-body ol { padding-left: 22px; margin: 6px 0; }
#detail-body li { margin: 2px 0; }
#detail-body table { border-collapse: collapse; margin: 8px 0; }
#detail-body th, #detail-body td {
border: 1px solid #e2e8f0; padding: 4px 8px; font-size: 12px;
}
#detail-body a.internal { color: #2563eb; cursor: pointer; }
#detail-body a.external { color: #2563eb; }
#detail-backlinks { margin-top: 18px; }
#detail-backlinks h2 { font-size: 13px; color: #64748b; margin-bottom: 6px; }
#detail-backlinks ul { padding-left: 18px; }
#detail-backlinks a { color: #2563eb; cursor: pointer; }
</style>
</head>
<body>
<header>
<div class="title">
<strong id="bundle-name"></strong>
<span class="muted">OKF bundle</span>
</div>
<div class="controls">
<input id="search" type="search" placeholder="Search title / id / tag">
<select id="filter-type">
<option value="">All types</option>
</select>
<select id="layout">
<option value="cose">cose (force)</option>
<option value="concentric">concentric</option>
<option value="breadthfirst">breadth-first</option>
<option value="circle">circle</option>
<option value="grid">grid</option>
</select>
<button id="reset">Reset view</button>
</div>
</header>
<main>
<section id="graph"></section>
<section id="detail">
<div id="detail-empty" class="muted">Click a node to see its details.</div>
<article id="detail-content" hidden>
<header class="detail-header">
<span class="type-chip" id="detail-type"></span>
<h1 id="detail-title"></h1>
<div class="muted" id="detail-id"></div>
</header>
<div class="badges" id="detail-badges"></div>
<dl class="frontmatter">
<dt>Description</dt><dd id="detail-description"></dd>
<dt>Resource</dt><dd id="detail-resource"></dd>
<dt>Tags</dt><dd id="detail-tags"></dd>
<dt>Generated</dt><dd id="detail-generated"></dd>
<dt>Verified</dt><dd id="detail-verified"></dd>
<dt>Sources</dt><dd id="detail-sources"></dd>
</dl>
<hr>
<div id="detail-body"></div>
<section id="detail-backlinks" hidden>
<h2>Cited by</h2>
<ul id="backlinks-list"></ul>
</section>
</article>
</section>
</main>
<script>
window.BUNDLE_NAME = "Ski 2027 \u2014 Skicircus Saalbach";
window.BUNDLE = {"nodes": [{"data": {"id": "ischgl/oferty/apartment-brandau-kappl", "label": "Apartment Brandau (Kappl)", "type": "oferta", "description": "Rodzinny dom z sze\u015bcioma apartamentami w Kappl \u2014 bierzemy cztery, ceny doliny zamiast cen Ischgl, PLAN A wariantu Ischgl.", "resource": "pages/ischgl/oferty/apartment-brandau-kappl.html", "tags": ["nocleg", "ischgl", "kappl", "plan-a"], "status": "stable", "generated": {}, "verified": [], "stale_after": "", "sources": [], "trust_tier": "unverified", "stale": false, "color": "#94a3b8", "size": 35}}, {"data": {"id": "ischgl/oferty/apartments-kappl", "label": "Apartments Kappl", "type": "oferta", "description": "Tyrolski dom apartamentowy 100 m od przystanku skibusa bez przesiadki do Ischgl \u2014 liczba wolnych jednostek do potwierdzenia, PLAN B wariantu Ischgl.", "resource": "pages/ischgl/oferty/apartments-kappl.html", "tags": ["nocleg", "ischgl", "kappl", "plan-b"], "status": "stable", "generated": {}, "verified": [], "stale_after": "", "sources": [], "trust_tier": "unverified", "stale": false, "color": "#94a3b8", "size": 33}}, {"data": {"id": "ischgl/region", "label": "Ischgl / Silvretta Arena + dolina Paznaun", "type": "region", "description": "Wariant alternatywny: 239 km Silvretta Areny z jazd\u0105 do bezc\u0142owego Samnaun, nocleg w ta\u0144szym Kappl \u2014 skipass zweryfikowany na 26/27, bez zni\u017cki dla 17-latk\u00f3w.", "resource": "pages/ischgl/region.html", "tags": ["region", "ischgl", "wariant", "skipass", "logistyka"], "status": "stable", "generated": {}, "verified": [], "stale_after": "", "sources": [], "trust_tier": "unverified", "stale": false, "color": "#94a3b8", "size": 41}}, {"data": {"id": "kronplatz/oferty/haeuslerhof-valdaora", "label": "H\u00e4uslerhof (Valdaora)", "type": "oferta", "description": "Gospodarstwo z dok\u0142adnie czterema apartamentami \u2014 grupa zajmuje ca\u0142y obiekt, anulacja bezp\u0142atna do 2 miesi\u0119cy przed, PLAN A wariantu Kronplatz.", "resource": "pages/kronplatz/oferty/haeuslerhof-valdaora.html", "tags": ["nocleg", "kronplatz", "valdaora", "plan-a"], "status": "stable", "generated": {}, "verified": [], "stale_after": "", "sources": [], "trust_tier": "unverified", "stale": false, "color": "#94a3b8", "size": 35}}, {"data": {"id": "kronplatz/oferty/residence-corones-valdaora", "label": "Residence Corones (Valdaora)", "type": "oferta", "description": "Rezydencja w centrum Valdaory z przystankiem skibusa przed domem i Digital Guestpassem w cenie \u2014 PLAN B wariantu Kronplatz.", "resource": "pages/kronplatz/oferty/residence-corones-valdaora.html", "tags": ["nocleg", "kronplatz", "valdaora", "plan-b"], "status": "stable", "generated": {}, "verified": [], "stale_after": "", "sources": [], "trust_tier": "unverified", "stale": false, "color": "#94a3b8", "size": 34}}, {"data": {"id": "kronplatz/region", "label": "Kronplatz / Plan de Corones (Po\u0142udniowy Tyrol)", "type": "region", "description": "Wariant alternatywny: 121 km na jednej g\u00f3rze plus ca\u0142y Dolomiti Superski w karnecie, baza w tanim Olang/Valdaora, junior do 18 lat bez dop\u0142aty.", "resource": "pages/kronplatz/region.html", "tags": ["region", "kronplatz", "wariant", "skipass", "logistyka"], "status": "stable", "generated": {}, "verified": [], "stale_after": "", "sources": [], "trust_tier": "unverified", "stale": false, "color": "#94a3b8", "size": 41}}, {"data": {"id": "oferty/alpenparks-rehrenberg", "label": "AlpenParks Apartments Rehrenberg", "type": "oferta", "description": "Najta\u0144szy nowy obiekt na li\u015bcie \u2014 przestronne apartamenty z prywatnymi saunami w Viehhofen, wariant rezerwowy o mieszanych opiniach, PLAN C.", "resource": "pages/oferty/alpenparks-rehrenberg.html", "tags": ["nocleg", "viehhofen", "plan-c"], "status": "stable", "generated": {}, "verified": [], "stale_after": "", "sources": [], "trust_tier": "unverified", "stale": false, "color": "#94a3b8", "size": 35}}, {"data": {"id": "oferty/alpenparks-sonnleiten", "label": "AlpenParks Hotel & Apartment Sonnleiten", "type": "oferta", "description": "Du\u017c
</script>
<script>
(function () {
const bundle = window.BUNDLE;
const bundleName = window.BUNDLE_NAME;
document.title = `${bundleName} — OKF Viewer`;
document.getElementById("bundle-name").textContent = bundleName;
// Populate type filter
const typeSelect = document.getElementById("filter-type");
for (const t of bundle.types) {
const opt = document.createElement("option");
opt.value = t;
opt.textContent = t;
typeSelect.appendChild(opt);
}
// Build reverse-link index for backlinks
const backlinks = {};
for (const edge of bundle.edges) {
const { source, target } = edge.data;
(backlinks[target] ||= []).push(source);
}
// Look up node label/type by id
const nodeIndex = {};
for (const n of bundle.nodes) nodeIndex[n.data.id] = n.data;
const cy = cytoscape({
container: document.getElementById("graph"),
elements: [...bundle.nodes, ...bundle.edges],
style: [
{
selector: "node",
style: {
"background-color": "data(color)",
"label": "data(label)",
"color": "#0f172a",
"font-size": 11,
"text-valign": "bottom",
"text-margin-y": 4,
"text-wrap": "wrap",
"text-max-width": 120,
"width": "data(size)",
"height": "data(size)",
"border-width": 1,
"border-color": "#0f172a",
},
},
{
// DODANE (gen_viz.py): węzły linków zewnętrznych. Inny kształt niż
// koncepty, żeby jedno i drugie dało się rozróżnić bez czytania
// etykiet i niezależnie od koloru.
selector: 'node[type = "external"]',
style: {
"shape": "round-diamond",
"border-style": "dotted",
"font-size": 10,
"color": "#0f766e",
},
},
{
selector: "node[?stale]",
style: {
"border-width": 2,
"border-color": "#b91c1c",
"border-style": "dashed",
},
},
{
selector: 'node[status = "deprecated"]',
style: {
"opacity": 0.55,
},
},
{
selector: "node:selected",
style: {
"border-width": 3,
"border-color": "#f59e0b",
},
},
{
selector: "edge",
style: {
"width": 1.5,
"line-color": "#cbd5e1",
"target-arrow-color": "#cbd5e1",
"target-arrow-shape": "triangle",
"curve-style": "bezier",
"arrow-scale": 0.9,
},
},
{
selector: "edge:selected",
style: {
"line-color": "#f59e0b",
"target-arrow-color": "#f59e0b",
"width": 2.5,
},
},
{
selector: ".dim",
style: { "opacity": 0.15 },
},
],
layout: { name: "cose", animate: false, padding: 30 },
wheelSensitivity: 0.2,
});
cy.on("tap", "node", (evt) => showDetail(evt.target.id()));
cy.on("tap", (evt) => {
if (evt.target === cy) clearSelection();
});
document.getElementById("layout").addEventListener("change", (e) => {
cy.layout({ name: e.target.value, animate: false, padding: 30 }).run();
});
document.getElementById("reset").addEventListener("click", () => {
cy.fit(null, 30);
clearSelection();
});
document.getElementById("search").addEventListener("input", (e) => {
const q = e.target.value.trim().toLowerCase();
if (!q) {
cy.elements().removeClass("dim");
return;
}
cy.nodes().forEach((n) => {
const d = n.data();
const hay =
(d.label || "").toLowerCase() + " " +
d.id.toLowerCase() + " " +
(d.tags || []).join(" ").toLowerCase();
n.toggleClass("dim", !hay.includes(q));
});
cy.edges().forEach((edge) => {
const src = edge.source();
const tgt = edge.target();
edge.toggleClass("dim", src.hasClass("dim") || tgt.hasClass("dim"));
});
});
document.getElementById("filter-type").addEventListener("change", (e) => {
const t = e.target.value;
if (!t) {
cy.elements().removeClass("dim");
return;
}
cy.nodes().forEach((n) => {
n.toggleClass("dim", n.data("type") !== t);
});
cy.edges().forEach((edge) => {
edge.toggleClass("dim", edge.source().hasClass("dim") || edge.target().hasClass("dim"));
});
});
function clearSelection() {
cy.elements().unselect();
document.getElementById("detail-empty").hidden = false;
document.getElementById("detail-content").hidden = true;
}
function showDetail(conceptId) {
const data = nodeIndex[conceptId];
if (!data) return;
cy.elements().unselect();
const node = cy.getElementById(conceptId);
if (node) node.select();
document.getElementById("detail-empty").hidden = true;
const content = document.getElementById("detail-content");
content.hidden = false;
const chip = document.getElementById("detail-type");
chip.textContent = data.type;
chip.style.background = data.color;
document.getElementById("detail-title").textContent = data.label;
document.getElementById("detail-id").textContent = conceptId;
document.getElementById("detail-description").textContent = data.description || "—";
const resourceEl = document.getElementById("detail-resource");
resourceEl.innerHTML = "";
if (data.resource) {
const a = document.createElement("a");
a.href = data.resource;
a.textContent = data.resource;
a.target = "_blank";
a.rel = "noopener";
a.className = "external";
resourceEl.appendChild(a);
} else {
resourceEl.textContent = "—";
}
const tagsEl = document.getElementById("detail-tags");
tagsEl.innerHTML = "";
if (data.tags && data.tags.length) {
for (const t of data.tags) {
const span = document.createElement("span");
span.className = "tag";
span.textContent = t;
tagsEl.appendChild(span);
}
} else {
tagsEl.textContent = "—";
}
// v0.2 signal badges: status, trust tier, staleness.
const badgesEl = document.getElementById("detail-badges");
badgesEl.innerHTML = "";
const status = data.status || "stable";
badgesEl.appendChild(makeBadge(status, "status-" + status));
const tier = data.trust_tier || "unverified";
badgesEl.appendChild(makeBadge(tier.replace(/-/g, " "), "trust-" + tier));
if (data.stale) {
const label = data.stale_after ? `stale (since ${data.stale_after})` : "stale";
badgesEl.appendChild(makeBadge(label, "stale"));
} else if (data.stale_after) {
badgesEl.appendChild(makeBadge(`stale after ${data.stale_after}`, "fresh"));
}
document.getElementById("detail-generated").textContent = formatActorEvent(data.generated);
const verifiedEl = document.getElementById("detail-verified");
const verified = data.verified || [];
if (verified.length) {
verifiedEl.textContent = verified.map(formatActorEvent).join("; ");
} else {
verifiedEl.textContent = "—";
}
const sourcesEl = document.getElementById("detail-sources");
sourcesEl.innerHTML = "";
const sources = data.sources || [];
if (sources.length) {
const ul = document.createElement("ul");
ul.className = "sources-list";
for (const s of sources) {
const li = document.createElement("li");
const label = s.title || s.resource || s.id || "source";
if (s.resource && /^https?:\/\//.test(s.resource)) {
const a = document.createElement("a");
a.href = s.resource;
a.textContent = label;
a.target = "_blank";
a.rel = "noopener";
a.className = "external";
li.appendChild(a);
} else {
li.textContent = s.resource ? `${label} (${s.resource})` : label;
}
ul.appendChild(li);
}
sourcesEl.appendChild(ul);
} else {
sourcesEl.textContent = "—";
}
const body = bundle.bodies[conceptId] || "";
const html = marked.parse(body, { breaks: false, gfm: true });
const bodyEl = document.getElementById("detail-body");
bodyEl.innerHTML = html;
rewriteInternalLinks(bodyEl);
const bl = backlinks[conceptId] || [];
const blSection = document.getElementById("detail-backlinks");
const blList = document.getElementById("backlinks-list");
blList.innerHTML = "";
if (bl.length) {
blSection.hidden = false;
for (const src of bl) {
const li = document.createElement("li");
const a = document.createElement("a");
a.textContent = nodeIndex[src]?.label || src;
a.dataset.target = src;
a.addEventListener("click", () => showDetail(src));
li.appendChild(a);
const muted = document.createElement("span");
muted.className = "muted";
muted.textContent = ` (${src})`;
li.appendChild(muted);
blList.appendChild(li);
}
} else {
blSection.hidden = true;
}
cy.animate({ center: { eles: node }, zoom: Math.max(cy.zoom(), 1.0) }, { duration: 200 });
}
function makeBadge(text, cls) {
const span = document.createElement("span");
span.className = "badge " + cls;
span.textContent = text;
return span;
}
function formatActorEvent(event) {
if (!event || !event.by) return "—";
return event.at ? `${event.by} · ${event.at}` : String(event.by);
}
function rewriteInternalLinks(root) {
root.querySelectorAll("a[href]").forEach((a) => {
const href = a.getAttribute("href");
if (!href) return;
if (href.startsWith("/") && href.endsWith(".md")) {
const target = href.slice(1, -3);
if (nodeIndex[target]) {
a.className = "internal";
a.setAttribute("href", "javascript:void(0)");
a.addEventListener("click", (e) => {
e.preventDefault();
showDetail(target);
});
return;
}
}
a.className = "external";
a.setAttribute("target", "_blank");
a.setAttribute("rel", "noopener");
});
}
// Auto-show the first node (a dataset if available, else first concept)
const initial =
bundle.nodes.find((n) => n.data.type === "BigQuery Dataset") ||
bundle.nodes[0];
if (initial) showDetail(initial.data.id);
})();
</script>
</body>
</html>