2026-07-31 15:48:32 +02:00
|
|
|
<!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";
|
Linki zewnętrzne jako węzły typu "external" w grafie
gen_viz.py wyciąga adresy z konceptów i dokłada je do window.BUNDLE jako
węzły typu "external" (teal #0d9488, odróżnialne od szarych konceptów)
z krawędzią koncept → link. Cztery niezależne źródła, każde wyłączalne
stałą SOURCES: md-link, bare-url, bare-domain, frontmatter.
Deduplikacja po znormalizowanym URL-u: bez fragmentu #, bez trailing
slash, schemat i host małymi literami. Odfiltrowane domeny techniczne:
github.com/GoogleCloudPlatform, localhost, 192.168.*, 100.*.
Klik w węzeł external otwiera URL w nowej karcie bez zmiany w viewerze:
pełny adres trafia do pola `resource`, które istniejący panel szczegółów
renderuje jako <a target="_blank" rel="noopener"> (viz.js:171-183). To
mniej inwazyjny wariant niż podpinanie się pod cy.on("tap", "node").
Uwaga: body konceptów nie zawiera dziś żadnego adresu http(s):// —
wszystkie adresy obiektów siedzą we frontmatterze (www:, gdzie_szukac:),
w body są tylko gołe domeny. Samo body + http/https dałoby 0 węzłów,
stąd źródła bare-domain i frontmatter.
Logika viewera nietknięta — viz.js i viz.css nadal bajt w bajt z
upstreamu. Bundle bez zmian: 0 zmodyfikowanych plików .md,
check_okf.py nadal zielony.
Wynik: 9 konceptów + 12 external, 23 krawędzie (9 + 14).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:25:54 +02:00
|
|
|
window.BUNDLE = {"nodes": [{"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": "", "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\u017cy aparthotel ~2 min pieszo od stoku w Saalbach, z basenem i saun\u0105 w cenie \u2014 najwi\u0119ksza szansa na 4 wolne apartamenty w jednym tygodniu.", "resource": "", "tags": ["nocleg", "saalbach", "aparthotel"], "status": "stable", "generated": {}, "verified": [], "stale_after": "", "sources": [], "trust_tier": "unverified", "stale": false, "color": "#94a3b8", "size": 37}}, {"data": {"id": "oferty/dom-dla-grupy", "label": "Du\u017cy dom wakacyjny 12\u201314 os. (do wyszukania)", "type": "oferta-koncept", "description": "Koncept wynaj\u0119cia jednego domu wakacyjnego dla ca\u0142ej grupy zamiast czterech osobnych apartament\u00f3w \u2014 klimat chaletowy w po\u0142owie ceny Glemm Lodge.", "resource": "", "tags": ["nocleg", "dom-wakacyjny", "koncept"], "status": "stable", "generated": {}, "verified": [], "stale_after": "", "sources": [], "trust_tier": "unverified", "stale": false, "color": "#94a3b8", "size": 38}}, {"data": {"id": "oferty/glemm-lodge", "label": "Glemm Lodge Apartments \u2b50\u2b50\u2b50\u2b50", "type": "oferta", "description": "Jedyna oferta z w pe\u0142ni zweryfikowanym cennikiem (~4 370 \u20ac/rodzin\u0119) \u2014 odrzucona bud\u017cetowo, s\u0142u\u017cy jako punkt odniesienia cenowego.", "resource": "", "tags": ["nocleg", "saalbach", "referencja"], "status": "stable", "generated": {}, "verified": [], "stale_after": "", "sources": [], "trust_tier": "unverified", "stale": false, "color": "#94a3b8", "size": 41}}, {"data": {"id": "oferty/kristall-schattbergxpress", "label": "Appartementhaus Kristall at SchattbergXpress", "type": "oferta", "description": "Apartamenty 50 m od gondoli Schattberg X-press w centrum Saalbach \u2014 najlepsza lokalizacja w rozwa\u017canym bud\u017cecie, PLAN A.", "resource": "", "tags": ["nocleg", "saalbach", "plan-a"], "status": "stable", "generated": {}, "verified": [], "stale_after": "", "sources": [], "trust_tier": "unverified", "stale": false, "color": "#94a3b8", "size": 39}}, {"data": {"id": "oferty/sportchalet-viehhofen", "label": "Sportchalet Viehhofen", "type": "oferta", "description": "Kameralny obiekt holenderskich gospodarzy w Viehhofen z ocen\u0105 5.0 \u2014 najlepszy stosunek ceny do jako\u015bci, PLAN B.", "resource": "", "tags": ["nocleg", "viehhofen", "plan-b"], "status": "stable", "generated": {}, "verified": [], "stale_after": "", "sources": [], "trust_tier": "unverified", "stale": false, "color": "#94a3b8", "size": 39}}, {"data": {"id": "region", "label": "Skicircus Saalbach-Hinterglemm-Leogang-Fieberbrunn", "type": "region", "description": "Wszystko poza noclegiem: teren narciarski, skipassy ALPIN CARD, dojazd z Warszawy, analiza termin\u00f3w, koszty \u017cycia i bud\u017cet ca\u0142kowity grupy.", "resource": "", "tags": ["region", "saalbach", "skipass", "logistyka"], "status": "stable", "generated": {}, "verified": [], "stale_after": "", "sources": [], "trust_tier": "unverified", "stale": false, "color": "#94a3b8", "size": 52}}, {"data": {"id": "szablon-maila", "label": "Szablon zapytania grupowego (EN)", "type": "narzedzie", "description": "Gotowy angielski mail z zapytaniem o 4 apartamenty dla 14 os\u00f3b w terminie 16\u201323.01.2027 wraz z notatkami negocjacyjnymi.", "resource": "", "tags": ["szablon", "komunikacja", "rezerwacja"], "status": "stable", "generated": {}, "verified": [], "stale_after": "", "sources": [], "trust_tier": "unverified", "stale": false, "color": "#94a3b8", "
|
2026-07-31 15:48:32 +02:00
|
|
|
</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",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
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>
|