|
| 1 | +<template> |
| 2 | + <div class="onto-wrap" ref="wrap"> |
| 3 | + <svg ref="svg" :viewBox="`0 0 ${W} ${H}`" xmlns="http://www.w3.org/2000/svg"> |
| 4 | + <!-- DL Box headers --> |
| 5 | + <g v-for="(box, bi) in boxes" :key="bi"> |
| 6 | + <rect :x="box.x - box.w/2" y="8" :width="box.w" height="26" rx="6" |
| 7 | + :fill="box.color" fill-opacity="0.1" :stroke="box.color" stroke-width="1" class="box-header"/> |
| 8 | + <text :x="box.x" y="26" text-anchor="middle" :fill="box.color" |
| 9 | + font-size="11" font-weight="700" font-family="system-ui" class="box-label">{{ box.label }}</text> |
| 10 | + </g> |
| 11 | + |
| 12 | + <!-- Edges (drawn first, behind nodes) --> |
| 13 | + <line v-for="(edge, ei) in edges" :key="'e'+ei" |
| 14 | + :x1="edge.x1" :y1="edge.y1" :x2="edge.x2" :y2="edge.y2" |
| 15 | + :stroke="edge.color" stroke-opacity="0.15" stroke-width="1" class="edge"/> |
| 16 | + |
| 17 | + <!-- Nodes --> |
| 18 | + <g v-for="(node, ni) in nodes" :key="'n'+ni" class="node-g" |
| 19 | + @mouseenter="hovered = ni" @mouseleave="hovered = null"> |
| 20 | + <circle :cx="node.x" :cy="node.y" :r="hovered === ni ? 7 : 4.5" |
| 21 | + :fill="node.color" :fill-opacity="hovered === ni ? 1 : 0.7" class="dot"/> |
| 22 | + <text :x="node.x + 9" :y="node.y + (hovered === ni ? -2 : 4)" |
| 23 | + :fill="hovered === ni ? 'var(--vp-c-text-1)' : 'var(--vp-c-text-2)'" |
| 24 | + :font-size="hovered === ni ? 12 : 10" font-family="system-ui" |
| 25 | + :font-weight="hovered === ni ? 700 : 400" class="label">{{ node.label }}</text> |
| 26 | + <text v-if="hovered === ni" :x="node.x + 9" :y="node.y + 12" |
| 27 | + fill="var(--vp-c-text-3)" font-size="8" font-family="system-ui" class="sublabel">{{ node.type }}</text> |
| 28 | + </g> |
| 29 | + |
| 30 | + <!-- Loading indicator --> |
| 31 | + <text v-if="loading" :x="W/2" :y="H - 10" text-anchor="middle" |
| 32 | + fill="var(--vp-c-text-3)" font-size="9" font-family="system-ui"> |
| 33 | + loading ontology... {{ loadedCount }}/7 |
| 34 | + </text> |
| 35 | + </svg> |
| 36 | + </div> |
| 37 | +</template> |
| 38 | + |
| 39 | +<script setup> |
| 40 | +import { ref, onMounted, onUnmounted } from 'vue' |
| 41 | +
|
| 42 | +const W = 920 |
| 43 | +const H = 400 |
| 44 | +const svg = ref(null) |
| 45 | +const wrap = ref(null) |
| 46 | +const hovered = ref(null) |
| 47 | +const loading = ref(true) |
| 48 | +const loadedCount = ref(0) |
| 49 | +const nodes = ref([]) |
| 50 | +const edges = ref([]) |
| 51 | +
|
| 52 | +const boxes = [ |
| 53 | + { label: 'TBox · Identity', x: 160, w: 180, color: '#0B6E2D' }, |
| 54 | + { label: 'RBox · Capability', x: 460, w: 180, color: '#B8860B' }, |
| 55 | + { label: 'ABox · Knowledge', x: 760, w: 180, color: '#6B21A8' }, |
| 56 | +] |
| 57 | +
|
| 58 | +// Map BFO types to DL boxes |
| 59 | +const boxMap = { |
| 60 | + 'Class': 0, 'Ontology': 0, |
| 61 | + 'Property': 1, |
| 62 | + 'Individual': 2, |
| 63 | +} |
| 64 | +
|
| 65 | +// Color by BFO category |
| 66 | +const colorMap = { |
| 67 | + 0: '#0B6E2D', // TBox green |
| 68 | + 1: '#B8860B', // RBox amber |
| 69 | + 2: '#6B21A8', // ABox purple |
| 70 | +} |
| 71 | +
|
| 72 | +let animeInstances = [] |
| 73 | +
|
| 74 | +onMounted(async () => { |
| 75 | + if (typeof window === 'undefined') return |
| 76 | +
|
| 77 | + // Load N3.js dynamically |
| 78 | + let N3 |
| 79 | + try { |
| 80 | + const script = document.createElement('script') |
| 81 | + script.src = 'https://cdn.jsdelivr.net/npm/n3@1.16.3/browser/n3.min.js' |
| 82 | + document.head.appendChild(script) |
| 83 | + await new Promise((resolve, reject) => { |
| 84 | + script.onload = resolve |
| 85 | + script.onerror = reject |
| 86 | + }) |
| 87 | + N3 = window.N3 |
| 88 | + } catch (e) { |
| 89 | + loading.value = false |
| 90 | + buildFallbackGraph() |
| 91 | + return |
| 92 | + } |
| 93 | +
|
| 94 | + // Fetch and parse TTL files |
| 95 | + const ttlFiles = [ |
| 96 | + '/ontology/v3.5-alpha6/core.ttl', |
| 97 | + '/ontology/v3.5-alpha6/kernel-metadata.ttl', |
| 98 | + '/ontology/v3.5-alpha6/processes.ttl', |
| 99 | + '/ontology/v3.5-alpha6/relations.ttl', |
| 100 | + '/ontology/v3.5-alpha6/base-instances.ttl', |
| 101 | + '/ontology/v3.5-alpha6/proof.ttl', |
| 102 | + '/ontology/v3.5-alpha6/rbac.ttl', |
| 103 | + ] |
| 104 | +
|
| 105 | + const store = new N3.Store() |
| 106 | + const CLASS_TYPES = [ |
| 107 | + 'http://www.w3.org/2002/07/owl#Class', |
| 108 | + 'http://www.w3.org/2000/01/rdf-schema#Class', |
| 109 | + ] |
| 110 | + const PROP_TYPES = [ |
| 111 | + 'http://www.w3.org/2002/07/owl#ObjectProperty', |
| 112 | + 'http://www.w3.org/2002/07/owl#DatatypeProperty', |
| 113 | + ] |
| 114 | +
|
| 115 | + for (const url of ttlFiles) { |
| 116 | + try { |
| 117 | + const resp = await fetch(url) |
| 118 | + if (!resp.ok) continue |
| 119 | + const text = await resp.text() |
| 120 | + const parser = new N3.Parser({ baseIRI: 'https://conceptkernel.org/ontology/v3.5/' }) |
| 121 | + const quads = parser.parse(text) |
| 122 | + store.addQuads(quads) |
| 123 | + loadedCount.value++ |
| 124 | + } catch (e) { /* skip failed files */ } |
| 125 | + } |
| 126 | +
|
| 127 | + loading.value = false |
| 128 | +
|
| 129 | + // Extract classes and properties |
| 130 | + const classes = new Set() |
| 131 | + const properties = new Set() |
| 132 | + const subClassEdges = [] |
| 133 | +
|
| 134 | + for (const q of store.getQuads(null, 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', null)) { |
| 135 | + if (CLASS_TYPES.includes(q.object.value)) classes.add(q.subject.value) |
| 136 | + if (PROP_TYPES.includes(q.object.value)) properties.add(q.subject.value) |
| 137 | + } |
| 138 | +
|
| 139 | + for (const q of store.getQuads(null, 'http://www.w3.org/2000/01/rdf-schema#subClassOf', null)) { |
| 140 | + if (classes.has(q.subject.value) && classes.has(q.object.value)) { |
| 141 | + subClassEdges.push({ from: q.subject.value, to: q.object.value }) |
| 142 | + } |
| 143 | + } |
| 144 | +
|
| 145 | + // Build node list — pick the most interesting classes (not all 507) |
| 146 | + const allEntities = [] |
| 147 | + classes.forEach(uri => { |
| 148 | + const localName = uri.includes('#') ? uri.split('#').pop() : uri.split('/').pop() |
| 149 | + if (localName && localName.length > 1 && !localName.startsWith('_')) |
| 150 | + allEntities.push({ uri, label: localName, type: 'Class', box: 0 }) |
| 151 | + }) |
| 152 | + properties.forEach(uri => { |
| 153 | + const localName = uri.includes('#') ? uri.split('#').pop() : uri.split('/').pop() |
| 154 | + if (localName && localName.length > 1 && !localName.startsWith('_')) |
| 155 | + allEntities.push({ uri, label: localName, type: 'Property', box: 1 }) |
| 156 | + }) |
| 157 | +
|
| 158 | + // Assign boxes and layout |
| 159 | + const grouped = { 0: [], 1: [], 2: [] } |
| 160 | + allEntities.forEach(e => { |
| 161 | + // Override box based on known patterns |
| 162 | + if (e.label.includes('Instance') || e.label.includes('Proof') || e.label.includes('Ledger') || e.label.includes('Project')) |
| 163 | + e.box = 2 |
| 164 | + else if (e.label.includes('Contract') || e.label.includes('Serving') || e.label.includes('Edge') || e.label.includes('Action') || e.label.includes('Process') || e.type === 'Property') |
| 165 | + e.box = 1 |
| 166 | +
|
| 167 | + if (grouped[e.box].length < 14) // Cap per column |
| 168 | + grouped[e.box].push(e) |
| 169 | + }) |
| 170 | +
|
| 171 | + // Position nodes |
| 172 | + const positioned = [] |
| 173 | + Object.entries(grouped).forEach(([boxIdx, items]) => { |
| 174 | + const bx = boxes[parseInt(boxIdx)].x |
| 175 | + const startY = 50 |
| 176 | + const spacing = (H - 70) / Math.max(items.length, 1) |
| 177 | + items.forEach((item, i) => { |
| 178 | + const jitter = (Math.random() - 0.5) * 40 |
| 179 | + positioned.push({ |
| 180 | + ...item, |
| 181 | + x: bx + jitter, |
| 182 | + y: startY + i * spacing + Math.random() * 10, |
| 183 | + color: colorMap[parseInt(boxIdx)], |
| 184 | + }) |
| 185 | + }) |
| 186 | + }) |
| 187 | +
|
| 188 | + nodes.value = positioned |
| 189 | +
|
| 190 | + // Build edges from subclass relationships |
| 191 | + const nodeMap = {} |
| 192 | + positioned.forEach((n, i) => { nodeMap[n.uri] = i }) |
| 193 | +
|
| 194 | + const edgeList = [] |
| 195 | + subClassEdges.forEach(({ from, to }) => { |
| 196 | + if (nodeMap[from] !== undefined && nodeMap[to] !== undefined) { |
| 197 | + const f = positioned[nodeMap[from]] |
| 198 | + const t = positioned[nodeMap[to]] |
| 199 | + edgeList.push({ x1: f.x, y1: f.y, x2: t.x, y2: t.y, color: f.color }) |
| 200 | + } |
| 201 | + }) |
| 202 | + edges.value = edgeList |
| 203 | +
|
| 204 | + // Animate |
| 205 | + startAnimations() |
| 206 | +}) |
| 207 | +
|
| 208 | +function buildFallbackGraph() { |
| 209 | + // Static fallback if N3.js fails to load |
| 210 | + const fallback = [ |
| 211 | + { label: 'Kernel', type: 'MaterialEntity', box: 0 }, |
| 212 | + { label: 'KernelOntology', type: 'Document', box: 0 }, |
| 213 | + { label: 'GovernanceMode', type: 'Quality', box: 0 }, |
| 214 | + { label: 'Action', type: 'PlanSpec', box: 1 }, |
| 215 | + { label: 'Edge', type: 'Artifact', box: 1 }, |
| 216 | + { label: 'ServingDisposition', type: 'Disposition', box: 1 }, |
| 217 | + { label: 'Instance', type: 'DataItem', box: 2 }, |
| 218 | + { label: 'ProofRecord', type: 'Proof', box: 2 }, |
| 219 | + { label: 'LedgerEntry', type: 'Ledger', box: 2 }, |
| 220 | + ] |
| 221 | + fallback.forEach((item, i) => { |
| 222 | + const bx = boxes[item.box].x |
| 223 | + nodes.value.push({ |
| 224 | + ...item, |
| 225 | + x: bx + (Math.random() - 0.5) * 30, |
| 226 | + y: 70 + (i % 3) * 80 + Math.random() * 20, |
| 227 | + color: colorMap[item.box], |
| 228 | + }) |
| 229 | + }) |
| 230 | + startAnimations() |
| 231 | +} |
| 232 | +
|
| 233 | +async function startAnimations() { |
| 234 | + if (typeof window === 'undefined' || !svg.value) return |
| 235 | + try { |
| 236 | + const anime = (await import('animejs/lib/anime.es.js')).default |
| 237 | +
|
| 238 | + // Fade in dots |
| 239 | + animeInstances.push(anime({ |
| 240 | + targets: svg.value.querySelectorAll('.dot'), |
| 241 | + opacity: [0, 0.7], |
| 242 | + r: [0, 4.5], |
| 243 | + delay: anime.stagger(40, { start: 200 }), |
| 244 | + duration: 600, |
| 245 | + easing: 'easeOutElastic(1, .6)' |
| 246 | + })) |
| 247 | +
|
| 248 | + // Fade in labels |
| 249 | + animeInstances.push(anime({ |
| 250 | + targets: svg.value.querySelectorAll('.label'), |
| 251 | + opacity: [0, 1], |
| 252 | + translateX: [-6, 0], |
| 253 | + delay: anime.stagger(40, { start: 400 }), |
| 254 | + duration: 400, |
| 255 | + easing: 'easeOutQuad' |
| 256 | + })) |
| 257 | +
|
| 258 | + // Draw edges |
| 259 | + animeInstances.push(anime({ |
| 260 | + targets: svg.value.querySelectorAll('.edge'), |
| 261 | + strokeDashoffset: [anime.setDashoffset, 0], |
| 262 | + opacity: [0, 0.15], |
| 263 | + delay: anime.stagger(30, { start: 300 }), |
| 264 | + duration: 800, |
| 265 | + easing: 'easeInOutQuad' |
| 266 | + })) |
| 267 | +
|
| 268 | + // Continuous breathing |
| 269 | + animeInstances.push(anime({ |
| 270 | + targets: svg.value.querySelectorAll('.dot'), |
| 271 | + r: [4.5, 6, 4.5], |
| 272 | + fillOpacity: [0.7, 0.9, 0.7], |
| 273 | + duration: 4000, |
| 274 | + loop: true, |
| 275 | + easing: 'easeInOutSine', |
| 276 | + delay: anime.stagger(150) |
| 277 | + })) |
| 278 | +
|
| 279 | + // Box header pulse |
| 280 | + animeInstances.push(anime({ |
| 281 | + targets: svg.value.querySelectorAll('.box-header'), |
| 282 | + fillOpacity: [0.08, 0.15, 0.08], |
| 283 | + duration: 5000, |
| 284 | + loop: true, |
| 285 | + easing: 'easeInOutSine', |
| 286 | + delay: anime.stagger(500) |
| 287 | + })) |
| 288 | +
|
| 289 | + } catch (e) { /* anime.js unavailable */ } |
| 290 | +} |
| 291 | +
|
| 292 | +onUnmounted(() => { |
| 293 | + animeInstances.forEach(a => a && a.pause && a.pause()) |
| 294 | +}) |
| 295 | +</script> |
| 296 | + |
| 297 | +<style scoped> |
| 298 | +.onto-wrap { |
| 299 | + max-width: 920px; |
| 300 | + margin: 0.5rem auto 1rem; |
| 301 | + padding: 0 0.5rem; |
| 302 | +} |
| 303 | +.onto-wrap svg { |
| 304 | + width: 100%; |
| 305 | + height: auto; |
| 306 | + min-height: 300px; |
| 307 | +} |
| 308 | +.node-g { cursor: default; transition: opacity 0.2s; } |
| 309 | +.node-g:hover .dot { filter: brightness(1.3); } |
| 310 | +
|
| 311 | +@media (max-width: 640px) { |
| 312 | + .onto-wrap svg { min-height: 200px; } |
| 313 | + .label { font-size: 8px !important; } |
| 314 | +} |
| 315 | +</style> |
0 commit comments