ea 0.6.3 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/config/themes/119.yml +4 -4
  4. data/frontend/dist/app.iife.js +8 -8
  5. data/frontend/src/App.vue +6 -2
  6. data/frontend/src/components/AppHeader.vue +35 -8
  7. data/frontend/src/components/AppSidebar.vue +2 -2
  8. data/frontend/src/components/ClassDetails.vue +103 -120
  9. data/frontend/src/components/DiagramView.vue +19 -6
  10. data/frontend/src/components/PackageDetails.vue +67 -26
  11. data/frontend/src/components/PackageTreeNode.vue +25 -20
  12. data/frontend/src/components/SearchResults.vue +21 -8
  13. data/frontend/src/components/WelcomeView.vue +2 -2
  14. data/frontend/src/stores/dataStore.ts +142 -48
  15. data/frontend/src/stores/uiStore.ts +3 -4
  16. data/frontend/src/types.ts +160 -163
  17. data/lib/ea/model/diagram_connector.rb +9 -0
  18. data/lib/ea/model/property.rb +6 -1
  19. data/lib/ea/sources/qea/classifier_builder.rb +3 -1
  20. data/lib/ea/sources/qea/diagram_builder.rb +91 -9
  21. data/lib/ea/sources/qea/property_builder.rb +12 -2
  22. data/lib/ea/spa/output/single_file_strategy.rb +1 -1
  23. data/lib/ea/spa/projector.rb +108 -6
  24. data/lib/ea/svg/ea_emitter/compartment/attributes.rb +1 -0
  25. data/lib/ea/svg/ea_emitter/compartment/constraints.rb +1 -0
  26. data/lib/ea/svg/ea_emitter/compartment/enum_literals.rb +9 -25
  27. data/lib/ea/svg/ea_emitter/compartment/header.rb +1 -0
  28. data/lib/ea/svg/ea_emitter/element/attribute_line_builder.rb +5 -0
  29. data/lib/ea/svg/ea_emitter/element/attribute_renderer.rb +51 -11
  30. data/lib/ea/svg/ea_emitter/element/compartment_geometry.rb +12 -10
  31. data/lib/ea/svg/ea_emitter/element/header_line_provider/name.rb +6 -15
  32. data/lib/ea/svg/ea_emitter/element/header_line_provider/stereotype_label.rb +25 -8
  33. data/lib/ea/svg/ea_emitter/element/header_renderer.rb +16 -4
  34. data/lib/ea/svg/ea_emitter/element/legend_renderer.rb +29 -4
  35. data/lib/ea/svg/ea_emitter/elements.rb +70 -20
  36. data/lib/ea/svg/ea_emitter/font_resolver.rb +5 -6
  37. data/lib/ea/svg/ea_emitter/label/end_label.rb +3 -1
  38. data/lib/ea/svg/ea_emitter/labels.rb +38 -2
  39. data/lib/ea/svg/ea_emitter/text_renderer.rb +97 -8
  40. data/lib/ea/svg/parity/checker.rb +19 -9
  41. data/lib/ea/svg/parity/corpus_report.rb +83 -0
  42. data/lib/ea/svg/parity/differ.rb +231 -0
  43. data/lib/ea/svg/parity.rb +2 -0
  44. data/lib/ea/theme/definition.rb +3 -3
  45. data/lib/ea/theme/loader.rb +2 -2
  46. data/lib/ea/version.rb +1 -1
  47. metadata +4 -2
@@ -1,14 +1,56 @@
1
1
  <script setup lang="ts">
2
- import { computed } from 'vue'
2
+ import { computed, watch } from 'vue'
3
3
  import { useDataStore } from '../stores/dataStore'
4
4
  import { useUiStore } from '../stores/uiStore'
5
+ import type { SpaPackagePayload } from '../types'
5
6
 
6
7
  const data = useDataStore()
7
8
  const ui = useUiStore()
8
9
 
10
+ const pkgId = computed(() => ui.currentPackageId)
9
11
  const pkg = computed(() =>
10
- ui.currentPackageId ? data.getPackageById(ui.currentPackageId) : null,
12
+ pkgId.value ? (data.elementFor(pkgId.value)?.payload as SpaPackagePayload | undefined) ?? null : null,
11
13
  )
14
+
15
+ watch(
16
+ pkgId,
17
+ async (id) => {
18
+ if (!id) return
19
+ const shard = await data.ensureElement(id)
20
+ if (!shard || ui.currentPackageId !== id) return
21
+ const p = shard.payload
22
+ await data.ensureElements([
23
+ ...(p.classifierIds || []),
24
+ ...(p.subPackageIds || []),
25
+ ...(p.diagramIds || []),
26
+ ])
27
+ },
28
+ { immediate: true },
29
+ )
30
+
31
+ function definitionOf(p: SpaPackagePayload | null): string {
32
+ return p?.annotations?.find((a) => a.kind === 'documentation')?.body || ''
33
+ }
34
+
35
+ const subPackageIds = computed(() => pkg.value?.subPackageIds ?? [])
36
+ const diagramIds = computed(() => pkg.value?.diagramIds ?? [])
37
+ const classifierIds = computed(() => pkg.value?.classifierIds ?? [])
38
+
39
+ function pkgName(id: string): string {
40
+ return data.nodesById[id]?.name || id
41
+ }
42
+
43
+ function diagramName(id: string): string {
44
+ return data.elementFor(id)?.payload?.name || id
45
+ }
46
+
47
+ function entryFor(id: string) {
48
+ return data.entriesById[id]
49
+ }
50
+
51
+ function elementFor(id: string) {
52
+ return data.elementFor(id)?.payload
53
+ }
12
54
  </script>
13
55
 
14
56
  <template>
@@ -16,49 +58,48 @@ const pkg = computed(() =>
16
58
  <div class="entity-header">
17
59
  <div class="entity-title">
18
60
  <h2 class="entity-name">{{ pkg.name }}</h2>
19
- <div class="entity-subtitle" v-if="pkg.path">{{ pkg.path }}</div>
20
61
  </div>
21
62
  <span class="entity-badge badge-package">Package</span>
22
63
  </div>
23
64
 
24
- <div class="entity-metadata" v-if="pkg.stereotypes.length">
65
+ <div class="entity-metadata" v-if="pkg.stereotypeRefs.length">
25
66
  <div class="metadata-item">
26
67
  <span class="metadata-label">Stereotypes</span>
27
68
  <span class="metadata-value">
28
- <span v-for="s in pkg.stereotypes" :key="s" class="stereotype-tag">&laquo;{{ s }}&raquo;</span>
69
+ <span v-for="s in pkg.stereotypeRefs" :key="s" class="stereotype-tag">&laquo;{{ s }}&raquo;</span>
29
70
  </span>
30
71
  </div>
31
72
  </div>
32
73
 
33
- <div class="entity-definition" v-if="pkg.definition">
34
- <div class="definition-content">{{ pkg.definition }}</div>
74
+ <div class="entity-definition" v-if="definitionOf(pkg)">
75
+ <div class="definition-content">{{ definitionOf(pkg) }}</div>
35
76
  </div>
36
77
 
37
- <div class="section" v-if="pkg.diagrams.length">
38
- <h3 class="section-title">Diagrams <span class="section-count">{{ pkg.diagrams.length }}</span></h3>
78
+ <div class="section" v-if="diagramIds.length">
79
+ <h3 class="section-title">Diagrams <span class="section-count">{{ diagramIds.length }}</span></h3>
39
80
  <div class="item-list">
40
- <div v-for="diagId in pkg.diagrams" :key="diagId"
81
+ <div v-for="diagId in diagramIds" :key="diagId"
41
82
  class="list-item clickable-row" @click="ui.selectDiagram(diagId)">
42
83
  <span class="list-item-icon">&#128202;</span>
43
- <span class="list-item-name">{{ data.getDiagramById(diagId)?.name || diagId }}</span>
84
+ <span class="list-item-name">{{ diagramName(diagId) }}</span>
44
85
  </div>
45
86
  </div>
46
87
  </div>
47
88
 
48
- <div class="section" v-if="pkg.subPackages.length">
49
- <h3 class="section-title">Sub-Packages <span class="section-count">{{ pkg.subPackages.length }}</span></h3>
89
+ <div class="section" v-if="subPackageIds.length">
90
+ <h3 class="section-title">Sub-Packages <span class="section-count">{{ subPackageIds.length }}</span></h3>
50
91
  <div class="item-list">
51
- <div v-for="subId in pkg.subPackages" :key="subId"
52
- class="list-item clickable-row" @click="ui.selectPackage(subId)">
92
+ <div v-for="subId in subPackageIds" :key="subId"
93
+ class="list-item clickable-row" @click="ui.selectPackage(subId, pkgName(subId))">
53
94
  <span class="list-item-icon">&#128193;</span>
54
- <span class="list-item-name">{{ data.getPackageById(subId)?.name || subId }}</span>
55
- <span class="tree-count">{{ data.getPackageById(subId)?.classes.length || 0 }}</span>
95
+ <span class="list-item-name">{{ pkgName(subId) }}</span>
96
+ <span class="tree-count">{{ data.nodesById[subId]?.classifierIds.length || 0 }}</span>
56
97
  </div>
57
98
  </div>
58
99
  </div>
59
100
 
60
- <div class="section" v-if="pkg.classes.length">
61
- <h3 class="section-title">Classes <span class="section-count">{{ pkg.classes.length }}</span></h3>
101
+ <div class="section" v-if="classifierIds.length">
102
+ <h3 class="section-title">Classes <span class="section-count">{{ classifierIds.length }}</span></h3>
62
103
  <div class="table-wrapper">
63
104
  <table class="data-table">
64
105
  <thead>
@@ -70,22 +111,22 @@ const pkg = computed(() =>
70
111
  </tr>
71
112
  </thead>
72
113
  <tbody>
73
- <tr v-for="clsId in pkg.classes" :key="clsId" class="clickable-row"
74
- @click="ui.selectClass(clsId)">
75
- <td>{{ data.getClassById(clsId)?.name }}</td>
76
- <td><span class="entity-badge" :class="'badge-' + (data.getClassById(clsId)?.type || 'class').toLowerCase()">{{ data.getClassById(clsId)?.type }}</span></td>
114
+ <tr v-for="clsId in classifierIds" :key="clsId" class="clickable-row"
115
+ @click="ui.selectClass(clsId, entryFor(clsId)?.name)">
116
+ <td>{{ entryFor(clsId)?.name || elementFor(clsId)?.name || clsId }}</td>
117
+ <td><span class="entity-badge" :class="'badge-' + (entryFor(clsId)?.kind || 'class')">{{ entryFor(clsId)?.kind || 'class' }}</span></td>
77
118
  <td>
78
- <span v-for="s in (data.getClassById(clsId)?.stereotypes || [])" :key="s"
119
+ <span v-for="s in (elementFor(clsId)?.stereotypeRefs || [])" :key="s"
79
120
  class="stereotype-tag">&laquo;{{ s }}&raquo;</span>
80
121
  </td>
81
- <td>{{ data.getClassById(clsId)?.attributes.length || 0 }}</td>
122
+ <td>{{ elementFor(clsId)?.properties?.length || 0 }}</td>
82
123
  </tr>
83
124
  </tbody>
84
125
  </table>
85
126
  </div>
86
127
  </div>
87
128
 
88
- <div class="empty-state" v-if="!pkg.classes.length && !pkg.subPackages.length && !pkg.diagrams.length">
129
+ <div class="empty-state" v-if="!classifierIds.length && !subPackageIds.length && !diagramIds.length">
89
130
  <p>This package is empty.</p>
90
131
  </div>
91
132
  </div>
@@ -2,32 +2,40 @@
2
2
  import { computed } from 'vue'
3
3
  import { useDataStore } from '../stores/dataStore'
4
4
  import { useUiStore } from '../stores/uiStore'
5
- import type { SpaPackageTreeNode } from '../types'
6
5
 
7
- const props = defineProps<{ node: SpaPackageTreeNode }>()
6
+ const props = defineProps<{ nodeId: string }>()
8
7
  const data = useDataStore()
9
8
  const ui = useUiStore()
10
9
 
11
- const isExpanded = computed(() => ui.expandedNodes.has(props.node.id))
10
+ const node = computed(() => data.nodesById[props.nodeId])
11
+ const isExpanded = computed(() => ui.expandedNodes.has(props.nodeId))
12
12
  const isSelected = computed(() =>
13
- ui.currentView === 'package' && ui.currentPackageId === props.node.id,
13
+ ui.currentView === 'package' && ui.currentPackageId === props.nodeId,
14
14
  )
15
- const children = computed(() => props.node.children || [])
16
- const nodeClasses = computed(() => props.node.classes || [])
15
+ const children = computed(() => node.value?.childIds ?? [])
16
+ const childNodes = computed(() =>
17
+ children.value.map((id) => data.nodesById[id]).filter((n) => !!n),
18
+ )
19
+ const classEntries = computed(() =>
20
+ (node.value?.classifierIds ?? [])
21
+ .map((id) => data.entriesById[id])
22
+ .filter((e) => !!e),
23
+ )
24
+ const classCount = computed(() => node.value?.classifierIds.length ?? 0)
17
25
 
18
26
  function toggle() {
19
- ui.toggleNode(props.node.id)
27
+ ui.toggleNode(props.nodeId)
20
28
  }
21
29
 
22
30
  function select() {
23
- ui.selectPackage(props.node.id, props.node.name)
31
+ ui.selectPackage(props.nodeId, node.value?.name)
24
32
  }
25
33
  </script>
26
34
 
27
35
  <template>
28
- <div class="tree-node">
36
+ <div class="tree-node" v-if="node">
29
37
  <div class="tree-node-content" :class="{ selected: isSelected }">
30
- <button class="tree-toggle" v-if="children.length || nodeClasses.length" @click.stop="toggle">
38
+ <button class="tree-toggle" v-if="children.length || classEntries.length" @click.stop="toggle">
31
39
  <svg width="12" height="12" viewBox="0 0 12 12" fill="none" :class="{ expanded: isExpanded }">
32
40
  <path d="M4 3l3 3-3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
33
41
  </svg>
@@ -41,23 +49,20 @@ function select() {
41
49
  </span>
42
50
 
43
51
  <span class="tree-label-group" @click="select">
44
- <span class="tree-stereotype" v-if="node.stereotypes && node.stereotypes.length">
45
- <span v-for="s in node.stereotypes" :key="s">&laquo;{{ s }}&raquo;</span>
46
- </span>
47
52
  <span class="tree-label">{{ node.name }}</span>
48
53
  </span>
49
54
 
50
- <span class="tree-count" v-if="node.classCount">{{ node.classCount }}</span>
55
+ <span class="tree-count" v-if="classCount">{{ classCount }}</span>
51
56
  </div>
52
57
 
53
58
  <div class="tree-children" v-if="isExpanded">
54
- <PackageTreeNode v-for="child in children" :key="child.id" :node="child" />
59
+ <PackageTreeNode v-for="child in childNodes" :key="child.id" :node-id="child.id" />
55
60
 
56
- <div v-for="cls in nodeClasses" :key="cls.id"
57
- class="tree-item" :class="{ selected: ui.currentClassId === cls.id }"
58
- @click="ui.selectClass(cls.id, cls.name)">
59
- <span class="badge badge-class">{{ cls.stereotypes.length ? cls.stereotypes[0][0] : 'C' }}</span>
60
- <span class="tree-item-label">{{ cls.name }}</span>
61
+ <div v-for="entry in classEntries" :key="entry.id"
62
+ class="tree-item" :class="{ selected: ui.currentClassId === entry.id }"
63
+ @click="ui.selectClass(entry.id, entry.name)">
64
+ <span class="badge badge-class">{{ entry.kind.charAt(0).toUpperCase() }}</span>
65
+ <span class="tree-item-label">{{ entry.name }}</span>
61
66
  </div>
62
67
  </div>
63
68
  </div>
@@ -6,14 +6,26 @@ import { useUiStore } from '../stores/uiStore'
6
6
  const data = useDataStore()
7
7
  const ui = useUiStore()
8
8
 
9
- const resultsByType = computed(() => {
9
+ const resultsByKind = computed(() => {
10
10
  const groups: Record<string, typeof data.searchEntries> = {}
11
11
  for (const entry of data.searchEntries) {
12
- const type = entry.type
13
- ;(groups[type] ??= []).push(entry)
12
+ ;(groups[entry.kind] ??= []).push(entry)
14
13
  }
15
14
  return groups
16
15
  })
16
+
17
+ function isClickable(kind: string): boolean {
18
+ return kind !== 'property'
19
+ }
20
+
21
+ function open(entry: { id: string; kind: string; name: string }) {
22
+ if (!isClickable(entry.kind)) return
23
+ if (entry.kind === 'package') {
24
+ ui.selectPackage(entry.id, entry.name)
25
+ } else {
26
+ ui.selectClass(entry.id, entry.name)
27
+ }
28
+ }
17
29
  </script>
18
30
 
19
31
  <template>
@@ -23,19 +35,20 @@ const resultsByType = computed(() => {
23
35
  <h2 class="entity-name">Search Results</h2>
24
36
  </div>
25
37
  </div>
26
- <div v-for="(entries, type) in resultsByType" :key="type" class="section">
27
- <h3 class="section-title">{{ type }}s <span class="section-count">{{ entries.length }}</span></h3>
38
+ <div v-for="(entries, kind) in resultsByKind" :key="kind" class="section">
39
+ <h3 class="section-title">{{ kind }}s <span class="section-count">{{ entries.length }}</span></h3>
28
40
  <div class="table-wrapper">
29
41
  <table class="data-table">
30
42
  <thead>
31
43
  <tr><th>Name</th><th>Package</th><th>Type</th></tr>
32
44
  </thead>
33
45
  <tbody>
34
- <tr v-for="entry in entries" :key="entry.id" class="clickable-row"
35
- @click="entry.type === 'class' ? ui.selectClass(entry.entityId) : ui.selectPackage(entry.entityId)">
46
+ <tr v-for="entry in entries" :key="entry.id" class="data-row"
47
+ :class="{ 'clickable-row': isClickable(entry.kind) }"
48
+ @click="open(entry)">
36
49
  <td>{{ entry.name }}</td>
37
50
  <td>{{ entry.package }}</td>
38
- <td><span class="entity-badge" :class="'badge-' + (entry.entityType || 'class').toLowerCase()">{{ entry.entityType }}</span></td>
51
+ <td><span class="entity-badge" :class="'badge-' + entry.kind">{{ entry.kind }}</span></td>
39
52
  </tr>
40
53
  </tbody>
41
54
  </table>
@@ -48,7 +48,7 @@ function formatDate(isoString?: string): string {
48
48
  <h1 class="welcome-title">{{ data.metadata?.title || 'UML Model Browser' }}</h1>
49
49
  <p class="welcome-subtitle">Explore UML model packages, classes, and associations</p>
50
50
 
51
- <div class="welcome-stats" v-if="data.metadata">
51
+ <div class="welcome-stats" v-if="data.metadata?.statistics">
52
52
  <div class="welcome-stat">
53
53
  <div class="welcome-stat-value">{{ data.metadata.statistics.packages }}</div>
54
54
  <div class="welcome-stat-label">Packages</div>
@@ -68,7 +68,7 @@ function formatDate(isoString?: string): string {
68
68
  </div>
69
69
 
70
70
  <div class="welcome-meta" v-if="data.metadata">
71
- <div class="meta-item">
71
+ <div class="meta-item" v-if="data.metadata.generator">
72
72
  <span class="meta-label">Generator</span>
73
73
  <span class="meta-value">{{ data.metadata.generator }}</span>
74
74
  </div>
@@ -1,79 +1,173 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import type {
3
3
  SpaData,
4
- SpaDocument,
5
- SpaSearchIndex,
4
+ SpaMetadata,
5
+ SpaPackageTreeNode,
6
+ SpaPackageTree,
6
7
  SpaSearchEntry,
7
- SpaClass,
8
- SpaPackage,
9
- SpaAttribute,
10
- SpaAssociation,
11
- SpaOperation,
12
- SpaDiagram,
8
+ SpaSearchIndex,
9
+ SpaShard,
10
+ SpaSkeletonEntry,
13
11
  } from '../types'
14
12
 
13
+ const SHARD_DIRS: Record<string, string> = {
14
+ class: 'classes',
15
+ enumeration: 'enumerations',
16
+ data_type: 'data_types',
17
+ primitive_type: 'primitive_types',
18
+ interface: 'interfaces',
19
+ package: 'packages',
20
+ diagram: 'diagrams',
21
+ property: 'properties',
22
+ }
23
+
24
+ const FETCH_CONCURRENCY = 12
25
+
26
+ const inflight = new Map<string, Promise<SpaShard | null>>()
27
+
15
28
  export const useDataStore = defineStore('data', {
16
29
  state: () => ({
17
- metadata: null as SpaDocument['metadata'] | null,
18
- packageTree: null as SpaDocument['packageTree'] | null,
19
- packages: {} as Record<string, SpaPackage>,
20
- classes: {} as Record<string, SpaClass>,
21
- attributes: {} as Record<string, SpaAttribute>,
22
- associations: {} as Record<string, SpaAssociation>,
23
- operations: {} as Record<string, SpaOperation>,
24
- diagrams: {} as Record<string, SpaDiagram>,
30
+ metadata: null as SpaMetadata | null,
31
+ packageTree: null as SpaPackageTree | null,
32
+ entries: [] as SpaSkeletonEntry[],
25
33
  searchEntries: [] as SpaSearchEntry[],
34
+ elements: {} as Record<string, SpaShard>,
35
+ shardBase: '',
26
36
  loaded: false,
27
37
  }),
28
38
 
29
39
  getters: {
30
- getClassById: (state) => (id: string) => state.classes[id],
31
- getPackageById: (state) => (id: string) => state.packages[id],
32
- getAttributeById: (state) => (id: string) => state.attributes[id],
33
- getAssociationById: (state) => (id: string) => state.associations[id],
34
- getOperationById: (state) => (id: string) => state.operations[id],
35
- getDiagramById: (state) => (id: string) => state.diagrams[id],
36
-
37
- classCount: (state) => Object.keys(state.classes).length,
38
- packageCount: (state) => Object.keys(state.packages).length,
39
- associationCount: (state) => Object.keys(state.associations).length,
40
- attributeCount: (state) => Object.keys(state.attributes).length,
40
+ entriesById(): Record<string, SpaSkeletonEntry> {
41
+ const map: Record<string, SpaSkeletonEntry> = {}
42
+ for (const e of this.entries) map[e.id] = e
43
+ return map
44
+ },
45
+
46
+ nodesById(): Record<string, SpaPackageTreeNode> {
47
+ const map: Record<string, SpaPackageTreeNode> = {}
48
+ if (this.packageTree) {
49
+ for (const n of this.packageTree.nodes) map[n.id] = n
50
+ }
51
+ return map
52
+ },
53
+
54
+ rootNodes(): SpaPackageTreeNode[] {
55
+ const tree = this.packageTree
56
+ if (!tree) return []
57
+ const map = this.nodesById
58
+ return tree.rootIds.map((id) => map[id]).filter((n) => !!n)
59
+ },
41
60
  },
42
61
 
43
62
  actions: {
44
63
  loadFromEmbedded() {
45
64
  const win = window as any
46
- if (!win.__SPA_DATA__) {
65
+ const raw = win.__SPA_DATA__ as SpaData
66
+ if (!raw) {
47
67
  throw new Error('No embedded SPA data found in window.__SPA_DATA__')
48
68
  }
49
- this.loadData(win.__SPA_DATA__)
69
+ this.metadata = raw.metadata
70
+ this.packageTree = raw.packageTree
71
+ this.entries = raw.entries || []
72
+ this.searchEntries = raw.searchIndex?.entries || []
73
+ this.hydrateShards(raw.shards || [])
74
+ this.loaded = true
50
75
  },
51
76
 
52
- async loadFromUrl(dataUrl: string, searchUrl: string) {
53
- const [dataRes, searchRes] = await Promise.all([
54
- fetch(dataUrl),
77
+ async loadFromSharded(skeletonUrl: string, searchUrl: string, shardBase: string) {
78
+ const [skeletonRes, searchRes] = await Promise.all([
79
+ fetch(skeletonUrl),
55
80
  fetch(searchUrl),
56
81
  ])
57
- const data = await dataRes.json()
58
- const searchIndex = await searchRes.json()
59
- this.loadData({ ...data, searchIndex })
82
+ if (!skeletonRes.ok) {
83
+ throw new Error(`Failed to load ${skeletonUrl}: ${skeletonRes.status}`)
84
+ }
85
+ if (!searchRes.ok) {
86
+ throw new Error(`Failed to load ${searchUrl}: ${searchRes.status}`)
87
+ }
88
+ const skeleton = await skeletonRes.json()
89
+ const searchIndex: SpaSearchIndex = await searchRes.json()
90
+ this.metadata = skeleton.metadata
91
+ this.packageTree = skeleton.packageTree
92
+ this.entries = skeleton.entries || []
93
+ this.searchEntries = searchIndex.entries || []
94
+ this.shardBase = shardBase || ''
95
+ this.loaded = true
60
96
  },
61
97
 
62
- loadData(raw: SpaData) {
63
- this.metadata = raw.metadata
64
- this.packageTree = raw.packageTree
65
- this.packages = raw.packages || {}
66
- this.classes = raw.classes || {}
67
- this.attributes = raw.attributes || {}
68
- this.associations = raw.associations || {}
69
- this.operations = raw.operations || {}
70
- this.diagrams = raw.diagrams || {}
71
- this.searchEntries = raw.searchIndex?.documentStore || []
72
- this.loaded = true
98
+ hydrateShards(shards: SpaShard[]) {
99
+ for (const shard of shards) this.elements[shard.id] = shard
100
+ },
101
+
102
+ elementFor(id: string): SpaShard | null {
103
+ return this.elements[id] || null
104
+ },
105
+
106
+ kindFor(id: string): string | null {
107
+ const entry = this.entriesById[id]
108
+ if (entry) return entry.kind
109
+ if (this.nodesById[id]) return 'package'
110
+ if (this.packageTree?.nodes.some((n) => n.diagramIds.includes(id))) {
111
+ return 'diagram'
112
+ }
113
+ return null
114
+ },
115
+
116
+ shardUrlFor(id: string, kind: string): string {
117
+ const dir = SHARD_DIRS[kind] || `${kind}s`
118
+ return `${this.shardBase}${dir}/${id}.json`
119
+ },
120
+
121
+ async ensureElement(id: string): Promise<SpaShard | null> {
122
+ const cached = this.elements[id]
123
+ if (cached) return cached
124
+
125
+ const kind = this.kindFor(id)
126
+ if (!kind) return null
127
+
128
+ const existing = inflight.get(id)
129
+ if (existing) return existing
130
+
131
+ const url = this.shardUrlFor(id, kind)
132
+ const pending = fetch(url)
133
+ .then(async (res) => {
134
+ if (!res.ok) {
135
+ throw new Error(`Failed to load ${url}: ${res.status}`)
136
+ }
137
+ const shard: SpaShard = await res.json()
138
+ this.elements[shard.id] = shard
139
+ return shard
140
+ })
141
+ .finally(() => {
142
+ inflight.delete(id)
143
+ })
144
+ inflight.set(id, pending)
145
+ return pending
146
+ },
147
+
148
+ async ensureElements(ids: string[]): Promise<void> {
149
+ const queue = [...ids]
150
+ const workers = Array.from(
151
+ { length: Math.min(FETCH_CONCURRENCY, queue.length) },
152
+ async () => {
153
+ for (;;) {
154
+ const id = queue.shift()
155
+ if (!id) return
156
+ try {
157
+ await this.ensureElement(id)
158
+ } catch {
159
+ // leave the element missing; views render what they have
160
+ }
161
+ }
162
+ },
163
+ )
164
+ await Promise.all(workers)
73
165
  },
74
166
 
75
- findClassByName(name: string): SpaClass | undefined {
76
- return Object.values(this.classes).find((c) => c.name === name)
167
+ classifierEntryByName(name: string): SpaSkeletonEntry | undefined {
168
+ return this.entries.find(
169
+ (e) => e.qualifiedName === name || e.name === name,
170
+ )
77
171
  },
78
172
  },
79
173
  })
@@ -132,16 +132,15 @@ export const useUiStore = defineStore('ui', {
132
132
 
133
133
  if (hash.startsWith('/package/')) {
134
134
  const id = hash.slice('/package/'.length)
135
- const pkg = data.getPackageById(id)
135
+ const pkg = data.nodesById[id]
136
136
  this.selectPackage(id, pkg?.name)
137
137
  } else if (hash.startsWith('/class/')) {
138
138
  const id = hash.slice('/class/'.length)
139
- const cls = data.getClassById(id)
139
+ const cls = data.entriesById[id]
140
140
  this.selectClass(id, cls?.name)
141
141
  } else if (hash.startsWith('/diagram/')) {
142
142
  const id = hash.slice('/diagram/'.length)
143
- const diag = data.getDiagramById(id)
144
- this.selectDiagram(id, diag?.name)
143
+ this.selectDiagram(id)
145
144
  }
146
145
  },
147
146