Files
rorappandClaude Opus 5.5 9442cc49d1
ci / test (push) Successful in 27s
ontology: Ontologie als Quelle im Repo, Abgleich mit n8n/KNOW in der Service-Routine
- ontology/know.yaml: 49 Klassen, 66 Beziehungen, 12 Vokabulare (72 Konzepte);
  erzeugt: know.ttl (OWL + SKOS), Ansicht aus viewer.html.tmpl
- knaster.ontology: Modell + Prüfung, Export, Momentaufnahme der n8n-Workflows
  (nur GET; KNOW-Spezifikation aus dem Workflow-Code, Aufrufe inkl. Laufzeit-
  Operationen, direktes SQL), Abgleich, Übernahme von Entscheidungen
- Service-Routine Schritt 8: Verstöße als Befunde, Ontologie-Änderungen als
  Vorschläge ontology_change (immer Freigabe); „ordne P-x <konzept> zu“;
  Entscheidungen in ontology_decisions, `knaster ontology apply` schreibt sie
  in die YAML (Version +1)
- CLI: knaster ontology validate|export|snapshot|check|apply; knaster validate
  prüft Modell und Aktualität der Turtle-Datei
- Gegen echten n8n-Export geprüft: neue Befunde B31–B33 (verified/confirmed,
  KAN ruft unbekannte Operation, MPA ordnet Projektereignisse nie ein)
- SPEC-ontology, BETRIEB §6, Router-Image enthält die Ontologie

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 02:54:49 +02:00

460 lines
30 KiB
Cheetah
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<title>KNOW Ontologie</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans+Condensed:wght@500;600&family=IBM+Plex+Sans:ital,wght@0,400;0,500;0,600;1,500&display=swap">
<style>
/* Layout: Begriffslandkarte – Klassen als beschriftete Kästen, Bereiche als Cluster; rechts das Register mit Definitionen und Turtle */
:root {
--bg: #f3f4f1;
--surface: #ffffff;
--fg: #1d2421;
--muted: #5f6b66;
--line: #d5dad6;
--edge: #8f9a94;
--key: #2e3a35;
--soft: #7b6a3f;
--ext: #5b7682;
--sys-bg: #1d2421;
--sys-fg: #f3f4f1;
--focus: #3f6f9f;
--warn: #b42318;
--d-mail: #3f7cac;
--d-kanbau: #2f8f6b;
--d-docs: #6d6a9e;
--d-kaf: #b8742a;
--d-finance: #8a5a44;
--d-learning: #7a8c2e;
--d-agent: #9b4f8f;
--d-bsb: #5b7682;
--d-llm: #c4471b;
--d-system: #1d2421;
--font-ui: "IBM Plex Sans", "Segoe UI", system-ui, sans-serif;
--font-head: "IBM Plex Sans Condensed", "IBM Plex Sans", "Arial Narrow", system-ui, sans-serif;
--font-mono: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #121615; --surface: #1a201e; --fg: #e3e8e5; --muted: #98a59f; --line: #2c3532; --edge: #5f6c66;
--key: #c9d2cd; --soft: #d2b56e; --ext: #8fb0bd; --sys-bg: #e3e8e5; --sys-fg: #121615; --focus: #78a9d6; --warn: #ff8a7a;
--d-mail: #6fa8d6; --d-kanbau: #5cc29b; --d-docs: #a29fd6; --d-kaf: #e0a35a; --d-finance: #c49176;
--d-learning: #b3c65a; --d-agent: #d283c4; --d-bsb: #8fb0bd; --d-llm: #f0784a; --d-system: #e3e8e5;
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #121615; --surface: #1a201e; --fg: #e3e8e5; --muted: #98a59f; --line: #2c3532; --edge: #5f6c66;
--key: #c9d2cd; --soft: #d2b56e; --ext: #8fb0bd; --sys-bg: #e3e8e5; --sys-fg: #121615; --focus: #78a9d6; --warn: #ff8a7a;
--d-mail: #6fa8d6; --d-kanbau: #5cc29b; --d-docs: #a29fd6; --d-kaf: #e0a35a; --d-finance: #c49176;
--d-learning: #b3c65a; --d-agent: #d283c4; --d-bsb: #8fb0bd; --d-llm: #f0784a; --d-system: #e3e8e5;
color-scheme: dark;
}
html, body { height: 100%; }
body { background: var(--bg); color: var(--fg); font-family: var(--font-ui); font-size: 14px; }
.app { height: 100%; display: grid; grid-template-rows: auto 1fr; }
header { display: flex; flex-wrap: wrap; gap: 10px 20px; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--line); background: var(--surface); }
.brand { display: flex; flex-direction: column; gap: 2px; min-width: 0; margin-right: auto; }
h1 { margin: 0; font-family: var(--font-head); font-weight: 600; font-size: 1.35rem; letter-spacing: .01em; text-wrap: balance; }
.brand p { margin: 0; color: var(--muted); font-size: .82rem; }
.controls { display: flex; flex-wrap: wrap; gap: 8px 14px; align-items: center; }
#q { font: inherit; padding: 6px 10px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--fg); width: 15rem; max-width: 100%; }
.toggle { display: inline-flex; align-items: center; gap: 6px; font-size: .85rem; color: var(--muted); cursor: pointer; user-select: none; }
.toggle input { accent-color: var(--focus); }
:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
main { display: grid; grid-template-columns: minmax(0, 1fr) 400px; min-height: 0; }
.stage { position: relative; min-width: 0; min-height: 0; }
svg#graph { width: 100%; height: 100%; display: block; cursor: grab; touch-action: none; }
.legend { position: absolute; left: 12px; bottom: 12px; right: 12px; display: flex; flex-wrap: wrap; gap: 6px; pointer-events: none; }
.legend > * { pointer-events: auto; }
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border-radius: 999px; border: 1px solid var(--line); background: var(--surface); font-size: .78rem; color: var(--fg); cursor: pointer; font-family: var(--font-ui); }
.chip[aria-pressed="true"] { border-color: var(--fg); }
.sw { width: 10px; height: 10px; border-radius: 2px; display: inline-block; flex: none; }
.edgekey { display: inline-flex; gap: 12px; flex-wrap: wrap; padding: 4px 10px; border-radius: 6px; background: var(--surface); border: 1px solid var(--line); font-size: .78rem; color: var(--muted); }
.edgekey span { display: inline-flex; align-items: center; gap: 6px; }
.edgekey i { display: inline-block; width: 22px; height: 0; border-top: 2px solid; }
aside { border-left: 1px solid var(--line); background: var(--surface); overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 14px; min-width: 0; }
aside h2 { margin: 0; font-family: var(--font-head); font-weight: 600; font-size: 1.2rem; overflow-wrap: anywhere; }
aside h3 { margin: 0 0 6px; font-size: .72rem; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); font-weight: 600; }
aside p { margin: 0; line-height: 1.5; }
.mono { font-family: var(--font-mono); font-size: .85em; }
.meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.tag { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: .75rem; border: 1px solid var(--line); color: var(--muted); }
.sensbox { border-left: 3px solid var(--d-kaf); padding: 6px 10px; background: var(--bg); border-radius: 4px; }
ul.rel { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; }
ul.rel li { display: grid; gap: 1px; }
.prop { font-family: var(--font-mono); font-size: .8rem; color: var(--muted); }
button.link { all: unset; cursor: pointer; color: var(--fg); font-weight: 500; text-decoration: underline; text-decoration-color: var(--line); text-underline-offset: 3px; overflow-wrap: anywhere; }
button.link:hover { text-decoration-color: currentColor; }
button.link:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 2px; }
.via { color: var(--muted); font-family: var(--font-mono); font-size: .74rem; overflow-wrap: anywhere; }
.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.stat { background: var(--bg); border-radius: 6px; padding: 8px 10px; display: grid; gap: 2px; }
.stat b { font-size: 1.3rem; font-family: var(--font-head); font-variant-numeric: tabular-nums; }
.stat span { font-size: .75rem; color: var(--muted); }
.vals { display: flex; flex-wrap: wrap; gap: 4px; }
.vals span { font-family: var(--font-mono); font-size: .74rem; padding: 1px 6px; border-radius: 4px; background: var(--bg); }
table.cols { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: .76rem; }
table.cols td { padding: 2px 0; border-bottom: 1px solid var(--line); vertical-align: top; overflow-wrap: anywhere; }
table.cols td:last-child { color: var(--muted); text-align: right; padding-left: 8px; white-space: nowrap; }
details summary { cursor: pointer; color: var(--muted); font-size: .8rem; }
pre.ttl { margin: 8px 0 0; max-height: 340px; overflow: auto; background: var(--bg); padding: 10px; border-radius: 6px; font-family: var(--font-mono); font-size: .72rem; line-height: 1.45; white-space: pre; }
.btn { font: inherit; font-size: .8rem; padding: 4px 10px; border-radius: 6px; border: 1px solid var(--line); background: var(--bg); color: var(--fg); cursor: pointer; }
.src { color: var(--muted); font-size: .75rem; line-height: 1.45; border-top: 1px solid var(--line); padding-top: 10px; }
.back { align-self: flex-start; font-size: .8rem; }
dl.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 12px; margin: 0; }
dl.kv dt { color: var(--muted); font-size: .8rem; }
dl.kv dd { margin: 0; min-width: 0; overflow-wrap: anywhere; }
/* SVG */
.cls { cursor: pointer; }
.cls rect.box { fill: var(--surface); stroke-width: 1.6; }
.cls.implicit rect.box { stroke-dasharray: 4 3; }
.cls.system rect.box { fill: var(--sys-bg); stroke: var(--sys-bg); }
.cls.system text { fill: var(--sys-fg); }
.cls text { font-family: var(--font-ui); font-size: 11.5px; font-weight: 500; fill: var(--fg); pointer-events: none; }
.cls.abstract text { font-style: italic; }
.cls rect.dot { stroke: none; }
.cls.sel rect.box { stroke: var(--focus); stroke-width: 3; }
.cls.match text { fill: var(--focus); font-weight: 600; }
.ind circle { fill: var(--surface); stroke: var(--muted); stroke-width: 1.2; }
.ind text { font-family: var(--font-mono); font-size: 9.5px; fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: 3px; pointer-events: none; }
.rel path { fill: none; stroke-width: 1.4; }
.rel.key path { stroke: var(--key); }
.rel.soft path { stroke: var(--soft); stroke-dasharray: 5 3; }
.rel.external path { stroke: var(--ext); stroke-dasharray: 1 3; stroke-linecap: round; stroke-width: 1.8; }
.rel.subclass path { stroke: var(--edge); stroke-width: 1.6; }
.rel.instance path { stroke: var(--edge); stroke-width: .8; opacity: .7; }
.rel text { font-family: var(--font-mono); font-size: 9.5px; fill: var(--muted); paint-order: stroke; stroke: var(--bg); stroke-width: 3.5px; stroke-linejoin: round; pointer-events: none; }
svg.nolabels .rel text { display: none; }
svg.focus .cls:not(.hl), svg.focus .ind:not(.hl) { opacity: .15; }
svg.focus .rel:not(.hl) { opacity: .06; }
.mk-key { fill: var(--key); } .mk-soft { fill: var(--soft); } .mk-ext { fill: var(--ext); } .mk-sub { fill: var(--bg); stroke: var(--edge); stroke-width: 1.2; }
@media (max-width: 860px) {
main { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(440px, 64vh) auto; }
aside { border-left: 0; border-top: 1px solid var(--line); overflow: visible; }
.app { height: auto; min-height: 100%; }
#q { width: 100%; }
.controls { width: 100%; }
}
</style>
<div class="app">
<header>
<div class="brand">
<h1>KNOW Ontologie</h1>
<p>Begriffe der Wissensdatenbank <span class="mono">knowledge</span> und wie sie zusammenhängen, abgeleitet aus Schema und Joins</p>
</div>
<div class="controls">
<input id="q" type="search" placeholder="Begriff suchen" aria-label="Begriff suchen" autocomplete="off">
<label class="toggle"><input type="checkbox" id="t-labels" checked> Beziehungsnamen</label>
<label class="toggle"><input type="checkbox" id="t-soft" checked> Weiche Bezüge</label>
<label class="toggle"><input type="checkbox" id="t-ext" checked> Externe Systeme</label>
<label class="toggle"><input type="checkbox" id="t-ind"> Ausprägungen</label>
</div>
</header>
<main>
<div class="stage">
<svg id="graph" role="img" aria-label="Ontologie als Netz aus Klassen und Beziehungen"></svg>
<div class="legend" id="legend"></div>
</div>
<aside id="panel" aria-live="polite"></aside>
</main>
</div>
<script type="application/json" id="data">__DATA__</script>
<script type="application/json" id="ttl">__TTL__</script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.9.0/d3.min.js"></script>
<script>
(function () {
const DATA = JSON.parse(document.getElementById('data').textContent);
const TTL = JSON.parse(document.getElementById('ttl').textContent);
const MODULES = {
mail: 'Mail & Absender', kanbau: 'Kontakte & Projekte', docs: 'Dokumente', kaf: 'Ankauf (KAF)', finance: 'Buchhaltung',
learning: 'Lernen', agent: 'Agent', bsb: 'Browser-Sessions', llm: 'LLM-Worker', system: 'Externe Systeme'
};
const KIND = { key: 'Schlüsselbezug', soft: 'weicher Bezug (Wertabgleich)', external: 'Verweis auf externes System', subclass: 'Unterklasse von', instance: 'Ausprägung von' };
const css = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim();
const modColor = m => css('--d-' + m);
const classes = DATA.classes.map(c => Object.assign({ kind: 'class' }, c));
const byId = new Map(classes.map(c => [c.id, c]));
const inds = [];
classes.forEach(c => c.individuals.forEach(v => inds.push({ id: c.id + '::' + v, label: v, kind: 'ind', cls: c.id, module: c.module })));
inds.forEach(i => byId.set(i.id, i));
const rels = DATA.relations.map(r => Object.assign({}, r));
inds.forEach(i => rels.push({ source: i.id, target: i.cls, name: 'a', via: '', kind: 'instance' }));
const allNodes = classes.concat(inds);
const state = { labels: true, soft: true, ext: true, ind: false, selected: null, module: null, query: '' };
const svg = d3.select('#graph');
const defs = svg.append('defs');
[['key', 'mk-key'], ['soft', 'mk-soft'], ['ext', 'mk-ext']].forEach(([k, cls]) => {
defs.append('marker').attr('id', 'm-' + k).attr('viewBox', '0 -4 8 8').attr('refX', 7).attr('refY', 0)
.attr('markerWidth', 7).attr('markerHeight', 7).attr('orient', 'auto')
.append('path').attr('d', 'M0,-4L8,0L0,4Z').attr('class', cls);
});
defs.append('marker').attr('id', 'm-sub').attr('viewBox', '0 -6 12 12').attr('refX', 11).attr('refY', 0)
.attr('markerWidth', 11).attr('markerHeight', 11).attr('orient', 'auto').attr('markerUnits', 'userSpaceOnUse')
.append('path').attr('d', 'M0,-6L11,0L0,6Z').attr('class', 'mk-sub');
const root = svg.append('g');
const gRels = root.append('g');
const gNodes = root.append('g');
const zoom = d3.zoom().scaleExtent([0.2, 3.5]).on('zoom', e => root.attr('transform', e.transform));
svg.call(zoom).on('dblclick.zoom', null);
svg.on('click', e => { if (e.target === svg.node()) select(null); });
// Textbreite messen, damit Kästen zur Beschriftung passen
const measure = (() => { const c = document.createElement('canvas').getContext('2d'); return (t, f) => { c.font = f; return c.measureText(t).width; }; })();
classes.forEach(c => { c.w = Math.max(54, measure(c.label, '500 11.5px "IBM Plex Sans", system-ui') + 26); c.h = 26; });
inds.forEach(i => { i.w = 8; i.h = 8; });
const modKeys = Object.keys(MODULES);
const anchors = {};
modKeys.forEach((m, i) => {
if (m === 'system') { anchors[m] = { x: 0, y: 0 }; return; }
const n = modKeys.length - 1, a = (i / n) * Math.PI * 2 - Math.PI / 2;
anchors[m] = { x: Math.cos(a) * 640, y: Math.sin(a) * 440 };
});
const sim = d3.forceSimulation()
.force('link', d3.forceLink().id(d => d.id).distance(l => l.kind === 'instance' ? 34 : l.kind === 'external' ? 200 : 140).strength(l => l.kind === 'instance' ? 0.9 : l.kind === 'external' ? 0.08 : 0.3))
.force('charge', d3.forceManyBody().strength(d => d.kind === 'ind' ? -40 : -560))
.force('collide', d3.forceCollide(d => d.kind === 'ind' ? 10 : d.w / 2 + 14))
.force('x', d3.forceX(d => anchors[d.module].x).strength(d => d.kind === 'ind' ? 0.01 : 0.12))
.force('y', d3.forceY(d => anchors[d.module].y).strength(d => d.kind === 'ind' ? 0.01 : 0.12))
.stop();
function visible() {
const ns = allNodes.filter(n => {
if (n.kind === 'ind') return state.ind;
if (n.module === 'system') return state.ext;
return true;
});
const ids = new Set(ns.map(n => n.id));
const ls = rels.filter(r => {
const s = typeof r.source === 'object' ? r.source.id : r.source, t = typeof r.target === 'object' ? r.target.id : r.target;
if (!ids.has(s) || !ids.has(t)) return false;
if (r.kind === 'soft') return state.soft;
return true;
});
return { ns, ls };
}
let relSel, nodeSel;
function render(initial) {
const { ns, ls } = visible();
sim.nodes(ns); sim.force('link').links(ls);
relSel = gRels.selectAll('g.rel').data(ls, r => r.source.id + '|' + r.name + '|' + r.target.id).join(enter => {
const g = enter.append('g');
g.append('path');
g.append('text').attr('text-anchor', 'middle').attr('dy', -3);
g.append('title');
return g;
});
relSel.attr('class', r => 'rel ' + r.kind);
relSel.select('path').attr('marker-end', r => r.kind === 'key' ? 'url(#m-key)' : r.kind === 'soft' ? 'url(#m-soft)' : r.kind === 'external' ? 'url(#m-ext)' : r.kind === 'subclass' ? 'url(#m-sub)' : null);
relSel.select('text').text(r => r.kind === 'instance' ? '' : r.kind === 'subclass' ? 'ist ein' : r.name);
relSel.select('title').text(r => r.kind === 'instance' ? `${r.source.label} ist Ausprägung von ${r.target.label}` : `${r.source.label} ${r.kind === 'subclass' ? 'ist Unterklasse von' : r.name} ${r.target.label}\n${KIND[r.kind]}${r.via ? '\nüber ' + r.via : ''}`);
nodeSel = gNodes.selectAll('g.node').data(ns, d => d.id).join(enter => {
const g = enter.append('g').attr('tabindex', 0).attr('role', 'button')
.attr('aria-label', d => (d.kind === 'ind' ? 'Ausprägung ' : 'Klasse ') + d.label);
g.each(function (d) {
const s = d3.select(this);
if (d.kind === 'ind') {
s.append('circle').attr('r', 4);
s.append('text').attr('x', 7).attr('y', 3).text(d.label);
} else {
s.append('rect').attr('class', 'box').attr('x', -d.w / 2).attr('y', -d.h / 2).attr('width', d.w).attr('height', d.h).attr('rx', 13);
if (d.module !== 'system') s.append('rect').attr('class', 'dot').attr('x', -d.w / 2 + 9).attr('y', -4).attr('width', 8).attr('height', 8).attr('rx', 2);
s.append('text').attr('x', d.module === 'system' ? 0 : 6).attr('y', 4).attr('text-anchor', 'middle').text(d.label);
}
});
g.on('click', (e, d) => { e.stopPropagation(); select(d.kind === 'ind' ? d.cls : d.id, false); })
.on('keydown', (e, d) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); select(d.kind === 'ind' ? d.cls : d.id); } })
.on('mouseenter', (e, d) => hover(d.kind === 'ind' ? d.cls : d.id)).on('mouseleave', () => hover(null))
.call(d3.drag()
.on('start', (e, d) => { if (!e.active) sim.alphaTarget(0.2).restart(); d.fx = d.x; d.fy = d.y; })
.on('drag', (e, d) => { d.fx = e.x; d.fy = e.y; })
.on('end', (e, d) => { if (!e.active) sim.alphaTarget(0); d.fx = null; d.fy = null; }));
return g;
});
nodeSel.attr('class', d => d.kind === 'ind' ? 'node ind' : ['node', 'cls', d.table ? '' : 'implicit', d.module === 'system' ? 'system' : '', d.abstract ? 'abstract' : ''].join(' '));
recolor();
const n = initial ? 360 : 120;
sim.alpha(initial ? 1 : 0.6); for (let i = 0; i < n; i++) sim.tick();
draw(); sim.on('tick', draw);
applyHighlight();
}
function recolor() {
if (!nodeSel) return;
nodeSel.select('rect.box').attr('stroke', d => d.module === 'system' ? null : modColor(d.module));
nodeSel.select('rect.dot').attr('fill', d => modColor(d.module));
}
// Kante am Kastenrand enden lassen
function edgePoint(n, tx, ty) {
const dx = tx - n.x, dy = ty - n.y;
if (n.kind === 'ind') { const l = Math.hypot(dx, dy) || 1; return [n.x + dx / l * 5, n.y + dy / l * 5]; }
const hw = n.w / 2 + 2, hh = n.h / 2 + 2;
const s = Math.min(Math.abs(dx) > 1e-6 ? hw / Math.abs(dx) : Infinity, Math.abs(dy) > 1e-6 ? hh / Math.abs(dy) : Infinity);
return [n.x + dx * s, n.y + dy * s];
}
function draw() {
relSel.each(function (r) {
const g = d3.select(this);
if (r.source === r.target) {
const n = r.source, sx = n.x + n.w / 4, sy = n.y - n.h / 2 - 2, ex = n.x + n.w / 2 + 2, ey = n.y;
g.select('path').attr('d', `M${sx},${sy}C${sx},${sy - 40} ${ex + 44},${ey} ${ex},${ey}`);
g.select('text').attr('x', ex + 18).attr('y', sy - 22);
return;
}
const [x1, y1] = edgePoint(r.source, r.target.x, r.target.y);
const [x2, y2] = edgePoint(r.target, r.source.x, r.source.y);
g.select('path').attr('d', `M${x1},${y1}L${x2},${y2}`);
g.select('text').attr('x', (x1 + x2) / 2).attr('y', (y1 + y2) / 2);
});
nodeSel.attr('transform', d => `translate(${d.x},${d.y})`);
}
function fit() {
const box = root.node().getBBox(); const w = svg.node().clientWidth, h = svg.node().clientHeight;
if (!box.width || !w || !h) return;
const k = Math.min(1.4, 0.94 / Math.max(box.width / w, box.height / (h - 60)));
svg.call(zoom.transform, d3.zoomIdentity.translate(w / 2 - k * (box.x + box.width / 2), (h - 50) / 2 - k * (box.y + box.height / 2)).scale(k));
}
let hoverId = null;
function hover(id) { hoverId = id; applyHighlight(); }
function applyHighlight() {
const f = hoverId || state.selected;
let ns = null, ls = null;
if (f) {
ns = new Set([f]); ls = new Set();
relSel.each(r => { if (r.source.id === f || r.target.id === f) { ns.add(r.source.id); ns.add(r.target.id); ls.add(r); } });
} else if (state.module) {
ns = new Set(allNodes.filter(n => n.module === state.module).map(n => n.id)); ls = new Set();
relSel.each(r => { if (r.source.module === state.module || r.target.module === state.module) { ls.add(r); ns.add(r.source.id); ns.add(r.target.id); } });
}
svg.classed('focus', !!ns);
nodeSel.classed('hl', d => !!ns && ns.has(d.id)).classed('sel', d => d.id === state.selected)
.classed('match', d => !!state.query && d.kind !== 'ind' && (d.label.toLowerCase().includes(state.query) || (d.table || '').includes(state.query)));
relSel.classed('hl', r => !!ls && ls.has(r));
}
// ---------- Panel ----------
const panel = document.getElementById('panel');
const esc = s => String(s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const btn = id => `<button class="link" data-id="${esc(id)}">${esc(byId.get(id).label)}</button>`;
const idOf = x => typeof x === 'object' ? x.id : x;
function relItems(list, dir) {
if (!list.length) return '<p class="via">keine</p>';
return '<ul class="rel">' + list.map(r => {
const other = dir === 'out' ? idOf(r.target) : idOf(r.source);
const name = r.kind === 'subclass' ? 'ist ein' : r.name;
const line = dir === 'out' ? `<span class="prop">${esc(name)} →</span> ${btn(other)}` : `${btn(other)} <span class="prop">${esc(name)} →</span>`;
return `<li><span>${line}</span><span class="via">${esc(KIND[r.kind])}${r.via ? ' · ' + esc(r.via) : ''}</span></li>`;
}).join('') + '</ul>';
}
function overview() {
const implicit = classes.filter(c => !c.table && c.module !== 'system').length;
const nRel = DATA.relations.filter(r => r.kind !== 'subclass').length;
panel.innerHTML = `
<div class="stats">
<div class="stat"><b>${classes.length}</b><span>Klassen, davon ${implicit} implizit</span></div>
<div class="stat"><b>${nRel}</b><span>Beziehungen</span></div>
<div class="stat"><b>${inds.length}</b><span>Ausprägungen</span></div>
</div>
<p>Jede der ${classes.filter(c => c.table).length} Tabellen ist eine Klasse. Dazu kommen <b>implizite Klassen</b> (gestrichelt): Begriffe ohne eigene Tabelle, die nur als Wert in Spalten vorkommen, etwa Postfach, n8n-Workflow, Gesellschaft oder Tier. Die Beziehungen stammen aus Schlüsselspalten und den Joins im KNOW-Code.</p>
<section><h3>Lesart</h3><dl class="kv">
<dt>Schlüsselbezug</dt><dd>gemeinsamer Schlüssel, z. B. <span class="mono">project_key</span></dd>
<dt>weicher Bezug</dt><dd>Abgleich über einen Wert wie E-Mail oder Adresse, ohne festen Schlüssel</dd>
<dt>externer Verweis</dt><dd>ID oder Pfad in KanBau, SharePoint oder Microsoft 365</dd>
<dt>ist ein</dt><dd>Unterklasse, z. B. Falldokument ist ein Dokument</dd>
</dl></section>
<section><h3>Auffällig</h3><p>${btn('Mail')} ist der zentrale Eingang: Absender, Anhang-Extraktion, Projektereignis und Rechnungsbeobachtung hängen an ihr. ${btn('Projekt')} ist der zentrale Bezugspunkt. ${btn('Ankaufsfall')} soll über <span class="mono">selected_project_id</span> zum Projekt werden; laut Befund B5 schreibt aber kein Workflow Ankaufsfälle.</p></section>
<section><h3>OWL / Turtle</h3>
<p>Dieselbe Ontologie als Turtle-Datei, mit Klassen, Objekt- und Datentyp-Properties und Ausprägungen. Quelle im Repo ist <span class="mono">ontology/know.yaml</span>, daraus erzeugt <span class="mono">ontology/know.ttl</span>.</p>
<div class="meta" style="margin-top:8px"><button class="btn" id="copy">Turtle kopieren</button><span class="via" id="copied" role="status"></span></div>
<details><summary>Turtle anzeigen</summary><pre class="ttl" id="ttlpre"></pre></details>
</section>
<p class="src">Erzeugt aus ontology/know.yaml (Version ${esc(DATA.version)}) am ${esc(DATA.generated)}. Grundlage: Postgres-Schema und SQL-Vorlagen aus dem n8n-Workflow „KNOW - Knowledge Base Gateway“. Datenbankinhalte wurden nicht gelesen. Die Namen der Beziehungen und die impliziten Klassen sind eine Modellierung aus diesen Quellen und nicht im Schema festgelegt.</p>`;
document.getElementById('ttlpre').textContent = TTL;
const status = document.getElementById('copied');
document.getElementById('copy').addEventListener('click', () => {
const done = () => { status.textContent = 'Kopiert'; };
const fallback = () => { const d = panel.querySelector('details'); d.open = true; const r = document.createRange(); r.selectNodeContents(document.getElementById('ttlpre')); const s = getSelection(); s.removeAllRanges(); s.addRange(r); status.textContent = 'Markiert, mit Strg/Cmd+C kopieren'; };
try { navigator.clipboard.writeText(TTL).then(done, fallback); } catch (e) { fallback(); }
});
}
function detail(id) {
const c = byId.get(id);
const out = rels.filter(r => idOf(r.source) === id && r.kind !== 'instance');
const inc = rels.filter(r => idOf(r.target) === id && r.kind !== 'instance');
const enums = Object.entries(c.enums || {});
panel.innerHTML = `<button class="link back" data-id="">← Überblick</button>
<h2>${esc(c.label)}</h2>
<div class="meta"><span class="chip" style="cursor:default"><span class="sw" style="background:var(--d-${c.module})"></span>${esc(MODULES[c.module])}</span>
${c.table ? `<span class="tag mono">${esc(c.table)}</span>` : c.module === 'system' ? '<span class="tag">externes System</span>' : '<span class="tag">implizit, ohne eigene Tabelle</span>'}
${c.abstract ? '<span class="tag">abstrakt</span>' : ''}</div>
<p>${esc(c.def)}</p>
<dl class="kv"><dt>Schlüssel</dt><dd class="mono">${esc(c.key)}</dd><dt>OWL</dt><dd class="mono">know:${esc(c.id)}</dd></dl>
${c.sens ? `<div class="sensbox"><h3>Sensible Inhalte</h3><p>${esc(c.sens)}</p></div>` : ''}
<section><h3>Beziehungen von hier</h3>${relItems(out, 'out')}</section>
<section><h3>Beziehungen hierher</h3>${relItems(inc, 'in')}</section>
${c.individuals.length ? `<section><h3>Ausprägungen</h3><div class="vals">${c.individuals.map(v => `<span>${esc(v)}</span>`).join('')}</div></section>` : ''}
${enums.map(([k, v]) => `<section><h3>Werte von ${esc(k)}</h3><div class="vals">${v.map(x => `<span>${esc(x)}</span>`).join('')}</div></section>`).join('')}
${c.attrs.length ? `<section><details><summary>${c.attrs.length} Attribute (Datentyp-Properties)</summary><table class="cols">${c.attrs.map(a => `<tr><td>${esc(a[0])}</td><td>${esc(a[1])}</td></tr>`).join('')}</table></details></section>` : ''}`;
}
panel.addEventListener('click', e => { const b = e.target.closest('button[data-id]'); if (b) select(b.getAttribute('data-id') || null, true); });
function select(id, center) {
state.selected = id; state.module = null; updateChips();
if (id) detail(id); else overview();
applyHighlight();
if (id && center) { const n = byId.get(id); if (n && n.x != null) svg.transition().duration(matchMedia('(prefers-reduced-motion: reduce)').matches ? 0 : 450).call(zoom.translateTo, n.x, n.y); }
}
// ---------- Legende und Steuerung ----------
const legend = document.getElementById('legend');
legend.innerHTML = modKeys.filter(m => m !== 'system').map(m => `<button class="chip" data-mod="${m}" aria-pressed="false"><span class="sw" style="background:var(--d-${m})"></span>${MODULES[m]}</button>`).join('') +
`<span class="edgekey"><span><i style="border-color:var(--key)"></i>Schlüssel</span><span><i style="border-color:var(--soft);border-top-style:dashed"></i>weich</span><span><i style="border-color:var(--ext);border-top-style:dotted"></i>extern</span><span><i style="border-color:var(--edge)"></i>▷ ist ein</span></span>`;
legend.addEventListener('click', e => {
const b = e.target.closest('[data-mod]'); if (!b) return;
const m = b.getAttribute('data-mod'); state.module = state.module === m ? null : m; state.selected = null; overview(); updateChips(); applyHighlight();
});
function updateChips() { legend.querySelectorAll('[data-mod]').forEach(b => b.setAttribute('aria-pressed', String(b.getAttribute('data-mod') === state.module))); }
[['t-labels', 'labels'], ['t-soft', 'soft'], ['t-ext', 'ext'], ['t-ind', 'ind']].forEach(([id, key]) => {
const el = document.getElementById(id);
try { const v = localStorage.getItem('know-onto:' + key); if (v !== null) el.checked = v === '1'; } catch (e) {}
state[key] = el.checked;
el.addEventListener('change', () => {
state[key] = el.checked;
try { localStorage.setItem('know-onto:' + key, el.checked ? '1' : '0'); } catch (e) {}
if (key === 'labels') { svg.classed('nolabels', !state.labels); return; }
if (state.selected && !visible().ns.some(n => n.id === state.selected)) select(null);
render(false);
});
});
svg.classed('nolabels', !state.labels);
const q = document.getElementById('q');
q.addEventListener('input', () => { state.query = q.value.trim().toLowerCase(); applyHighlight(); });
q.addEventListener('keydown', e => {
if (e.key !== 'Enter' || !state.query) return;
const hit = classes.find(c => c.label.toLowerCase().includes(state.query) || (c.table || '').includes(state.query));
if (hit && visible().ns.includes(hit)) select(hit.id, true);
});
const mq = matchMedia('(prefers-color-scheme: dark)');
mq.addEventListener && mq.addEventListener('change', recolor);
new MutationObserver(recolor).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
render(true);
overview();
requestAnimationFrame(fit);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => {
classes.forEach(c => { c.w = Math.max(54, measure(c.label, '500 11.5px "IBM Plex Sans", system-ui') + 26); });
nodeSel.select('rect.box').attr('x', d => -d.w / 2).attr('width', d => d.w);
nodeSel.select('rect.dot').attr('x', d => -d.w / 2 + 9);
draw();
});
window.addEventListener('resize', () => requestAnimationFrame(fit));
})();
</script>