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>
|
2026-08-03 17:52:23 +02:00
|
|
|
<span class="muted">· powered by <a href="https://gethumanai.pl" target="_blank" rel="noopener" style="color:inherit">gethumanai.pl</a></span>
|
2026-07-31 15:48:32 +02:00
|
|
|
</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";
|
Ischgl: rozstrzygnięcie VIP vs Silvretta — obowiązuje Silvretta
Tańsze stawki 385/231 € to VIP Skipass: tylko dla nocujących
w Ischgl/Mathon, 46 urządzeń, NIE działa na wyciągach Kappl. Nasz
PLAN A to nocleg w Kappl, więc obowiązuje Silvretta Skipass
(451/257 €, 75 urządzeń z Kappl i See) — ceny w tabelach zostają bez
zmian, zmienia się ich uzasadnienie.
Przy okazji dwa błędy w karcie, które rozstrzygnięcie odsłoniło:
tabela 451/257 € była podpisana "VIP Skipass" (to Silvretta), a sekcja
"Teren" przypisywała VIP-owi zasięg całego Paznaun łącznie z Kappl —
czyli dokładnie to, czego VIP nie obejmuje.
Pułapka wiekowa 8–<17 potwierdzona i zostaje w wyjazd.md; dopisany
plus po stronie Silvretty: dzieci <8 lat gratis (przy dwójce takich
dzieci grupa schodzi z ~5 150 € do ~4 640 €). Nowy wiersz "Dzieci
gratis" w tabeli porównawczej — b.d. tam, gdzie bundle nie ma danych,
zamiast domyślnego "brak".
Źródło: ischgl.com, cennik 26/27 (26.11.2026–02.05.2027), sprawdzone
03.08.2026.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 16:53:53 +02:00
|
|
|
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": 52}}, {"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": 45}}, {"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
|
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",
|
|
|
|
|
},
|
|
|
|
|
},
|
Viewer: jedna reguła stylu dla węzłów typu "external"
Jedyna ingerencja w logikę viewera w tym repo, wydzielona do osobnego
commita zgodnie z ustaleniem.
Kolor węzła jest sterowany danymi ("background-color": "data(color)"),
więc odróżnienie kolorem załatwiła sama warstwa danych (poprzedni commit,
zero zmian w viewerze). Kształt już nie — cytoscape'owa tablica stylów
siedzi w viz.js. Dokładamy do niej DOKŁADNIE jedną regułę:
selector: 'node[type = "external"]'
shape: round-diamond, border-style: dotted, font-size: 10
To ta sama konwencja co istniejące reguły node[?stale] i
node[status = "deprecated"] — selektor po polu danych. Reguła jest
wstawiana przed node[?stale], czyli przed node:selected, więc
podświetlenie zaznaczenia nadal wygrywa.
Podmiana dzieje się w locie, na stringu wstawianym do viz.html; plik
viz.js w repo upstream pozostaje nietknięty. Jeśli upstream zmieni
kotwicę, gen_viz.py przerywa z błędem zamiast po cichu wygenerować
viewer bez reguły.
Zweryfikowane: viz.js w viz.html == upstream + ta jedna reguła (diff
bajtowy równy jej długości), viz.css nadal bajt w bajt, składnia
osadzonego JS-a sprawdzona node --check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:27:19 +02:00
|
|
|
{
|
|
|
|
|
// 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",
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-07-31 15:48:32 +02:00
|
|
|
{
|
|
|
|
|
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>
|