ci / test (push) Successful in 27s
- 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>
460 lines
30 KiB
Cheetah
460 lines
30 KiB
Cheetah
<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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|