@glossarist/concept-browser 0.7.80 → 0.7.82

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.80",
3
+ "version": "0.7.82",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,169 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { mount } from '@vue/test-utils';
3
+ import PartitiveRelationDiagram, {
4
+ type PartitiveMemberLabeled,
5
+ } from '../../components/PartitiveRelationDiagram.vue';
6
+
7
+ const members = (n: number, certainty: 'confirmed' | 'possible' = 'confirmed'): PartitiveMemberLabeled[] =>
8
+ Array.from({ length: n }, (_, i) => ({
9
+ uri: `https://example.org/test/concept/1.${i + 1}`,
10
+ label: `1.${i + 1}`,
11
+ certainty,
12
+ }));
13
+
14
+ function svgLinesOf(wrapper: ReturnType<typeof mount>) {
15
+ return wrapper.findAll('line');
16
+ }
17
+
18
+ describe('PartitiveRelationDiagram — ISO 704 rake rendering', () => {
19
+ it('renders comprehensive + N partitive nodes as text', () => {
20
+ const w = mount(PartitiveRelationDiagram, {
21
+ props: {
22
+ comprehensiveLabel: '1.3',
23
+ partitives: members(3),
24
+ completeness: 'complete',
25
+ plurality: null,
26
+ },
27
+ });
28
+ const texts = w.findAll('text').map(t => t.text());
29
+ expect(texts).toContain('1.3');
30
+ expect(texts).toContain('1.1');
31
+ expect(texts).toContain('1.2');
32
+ expect(texts).toContain('1.3');
33
+ });
34
+
35
+ it('renders criterion under the comprehensive', () => {
36
+ const w = mount(PartitiveRelationDiagram, {
37
+ props: {
38
+ comprehensiveLabel: '1.3',
39
+ partitives: members(2),
40
+ completeness: 'complete',
41
+ plurality: null,
42
+ criterion: 'physical structure',
43
+ },
44
+ });
45
+ expect(w.findAll('text').map(t => t.text())).toContain('physical structure');
46
+ });
47
+
48
+ describe('line variant by plurality', () => {
49
+ it('single solid line when no plurality (3 lines: stem + spine + 2 drops = 4 actually)', () => {
50
+ const w = mount(PartitiveRelationDiagram, {
51
+ props: {
52
+ comprehensiveLabel: '1.3',
53
+ partitives: members(2),
54
+ completeness: 'complete',
55
+ plurality: null,
56
+ },
57
+ });
58
+ // stem (1) + spine (1) + drops (2) = 4 single lines
59
+ expect(svgLinesOf(w)).toHaveLength(4);
60
+ // none should be dashed
61
+ const dashed = svgLinesOf(w).filter(l => l.attributes('stroke-dasharray'));
62
+ expect(dashed).toHaveLength(0);
63
+ });
64
+
65
+ it('close-set double solid lines when isShared && !isUncertain', () => {
66
+ const w = mount(PartitiveRelationDiagram, {
67
+ props: {
68
+ comprehensiveLabel: '1.3',
69
+ partitives: members(2),
70
+ completeness: 'complete',
71
+ plurality: { isShared: true, isUncertain: false },
72
+ },
73
+ });
74
+ // stem doubles (2) + spine doubles (2) + drops (2, single) = 6 lines
75
+ expect(svgLinesOf(w)).toHaveLength(6);
76
+ const dashed = svgLinesOf(w).filter(l => l.attributes('stroke-dasharray'));
77
+ expect(dashed).toHaveLength(0);
78
+ });
79
+
80
+ it('one solid + one dashed when isShared && isUncertain', () => {
81
+ const w = mount(PartitiveRelationDiagram, {
82
+ props: {
83
+ comprehensiveLabel: '1.3',
84
+ partitives: members(2),
85
+ completeness: 'complete',
86
+ plurality: { isShared: true, isUncertain: true },
87
+ },
88
+ });
89
+ // stem (2: 1 solid + 1 dashed) + spine (2: 1 solid + 1 dashed) + drops (2, single solid) = 6
90
+ const lines = svgLinesOf(w);
91
+ expect(lines).toHaveLength(6);
92
+ const dashed = lines.filter(l => l.attributes('stroke-dasharray'));
93
+ // exactly 2 dashed lines (the second stem + the second spine)
94
+ expect(dashed).toHaveLength(2);
95
+ });
96
+ });
97
+
98
+ describe('completeness: partial extends spine', () => {
99
+ it('extends spine past last tooth when completeness=partial', () => {
100
+ const w = mount(PartitiveRelationDiagram, {
101
+ props: {
102
+ comprehensiveLabel: '1.3',
103
+ partitives: members(2),
104
+ completeness: 'partial',
105
+ plurality: null,
106
+ },
107
+ });
108
+ const lines = svgLinesOf(w);
109
+ // stem + spine + 2 drops = 4 lines (same count, but spine is longer)
110
+ expect(lines).toHaveLength(4);
111
+ // The spine line's x2 should be > the last partitive's x
112
+ const lastMember = w.props('partitives')[1];
113
+ // visual assertion: the spine line's x2 is greater than the second
114
+ // partitive's slot x. We can't compute slot exactly without internals,
115
+ // but we know PARTIAL_TAIL (32) is added.
116
+ const spineLine = lines.find(l => {
117
+ const y1 = l.attributes('y1');
118
+ const y2 = l.attributes('y2');
119
+ return y1 && y2 && y1 === y2; // horizontal
120
+ });
121
+ expect(spineLine).toBeDefined();
122
+ const x2 = Number(spineLine!.attributes('x2'));
123
+ // last member x ≈ PADDING_X + NODE_W * 1.5 + GAP_X = 24 + 180 + 16 = 220
124
+ // spine end with PARTIAL_TAIL = 220 + 32 = 252
125
+ expect(x2).toBeGreaterThan(220);
126
+ void lastMember;
127
+ });
128
+ });
129
+
130
+ describe('per-member certainty', () => {
131
+ it('renders possible members with dashed border', () => {
132
+ const possibleMembers: PartitiveMemberLabeled[] = [
133
+ { uri: 'u1', label: '1', certainty: 'confirmed' },
134
+ { uri: 'u2', label: '2', certainty: 'possible' },
135
+ ];
136
+ const w = mount(PartitiveRelationDiagram, {
137
+ props: {
138
+ comprehensiveLabel: '0',
139
+ partitives: possibleMembers,
140
+ completeness: 'complete',
141
+ plurality: null,
142
+ },
143
+ });
144
+ // Find the rect for the 'possible' member (the 2nd node)
145
+ const rects = w.findAll('rect');
146
+ // 1 comp + 2 partitive = 3 rects
147
+ expect(rects).toHaveLength(3);
148
+ // The possible member's rect should have a dashed border
149
+ const possibleRect = rects[2];
150
+ expect(possibleRect.attributes('stroke-dasharray')).toBeTruthy();
151
+ });
152
+
153
+ it('emits navigate when a partitive is clicked', async () => {
154
+ const w = mount(PartitiveRelationDiagram, {
155
+ props: {
156
+ comprehensiveLabel: '0',
157
+ partitives: members(2),
158
+ completeness: 'complete',
159
+ plurality: null,
160
+ },
161
+ });
162
+ // Click any of the partitive <g> elements
163
+ const groups = w.findAll('g.cursor-pointer');
164
+ expect(groups.length).toBeGreaterThan(0);
165
+ await groups[0].trigger('click');
166
+ expect(w.emitted('navigate')).toBeTruthy();
167
+ });
168
+ });
169
+ });
@@ -0,0 +1,146 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { conceptFromJson } from '../adapters/model-bridge';
3
+
4
+ describe('conceptFromJson — gl:partitiveRelations (v2)', () => {
5
+ function makeJson(overrides: Record<string, unknown> = {}) {
6
+ return {
7
+ '@type': 'gl:Concept',
8
+ '@id': 'https://example.org/test/concept/1.3',
9
+ 'gl:identifier': '1.3',
10
+ 'gl:localizedConcept': {},
11
+ ...overrides,
12
+ };
13
+ }
14
+
15
+ it('returns empty array when no partitiveRelations', () => {
16
+ const c = conceptFromJson(makeJson());
17
+ expect(c.partitiveRelations).toEqual([]);
18
+ });
19
+
20
+ it('parses comprehensive + 2 partitives', () => {
21
+ const c = conceptFromJson(makeJson({
22
+ 'gl:partitiveRelations': [{
23
+ 'gl:comprehensive': { 'gl:source': 'urn:test', 'gl:id': '1.3' },
24
+ 'gl:hasPartitive': [
25
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
26
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.5' } },
27
+ ],
28
+ }],
29
+ }));
30
+ expect(c.partitiveRelations).toHaveLength(1);
31
+ const r = c.partitiveRelations[0];
32
+ expect(r.comprehensive.source).toBe('urn:test');
33
+ expect(r.comprehensive.id).toBe('1.3');
34
+ expect(r.partitives).toHaveLength(2);
35
+ expect(r.partitives[0].ref.id).toBe('1.4');
36
+ expect(r.partitives[1].ref.id).toBe('1.5');
37
+ });
38
+
39
+ it('parses completeness', () => {
40
+ const c = conceptFromJson(makeJson({
41
+ 'gl:partitiveRelations': [{
42
+ 'gl:comprehensive': { 'gl:source': 'urn:test', 'gl:id': '1.3' },
43
+ 'gl:hasPartitive': [
44
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
45
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.5' } },
46
+ ],
47
+ 'gl:completeness': 'partial',
48
+ }],
49
+ }));
50
+ expect(c.partitiveRelations[0].completeness).toBe('partial');
51
+ expect(c.partitiveRelations[0].isPartial).toBe(true);
52
+ });
53
+
54
+ it('parses per-member certainty', () => {
55
+ const c = conceptFromJson(makeJson({
56
+ 'gl:partitiveRelations': [{
57
+ 'gl:comprehensive': { 'gl:source': 'urn:test', 'gl:id': '1.3' },
58
+ 'gl:hasPartitive': [
59
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
60
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.5' }, 'gl:certainty': 'possible' },
61
+ ],
62
+ }],
63
+ }));
64
+ const members = c.partitiveRelations[0].partitives;
65
+ expect(members[0].certainty).toBe('confirmed');
66
+ expect(members[1].certainty).toBe('possible');
67
+ expect(members[1].isPossible).toBe(true);
68
+ });
69
+
70
+ it('parses plurality block', () => {
71
+ const c = conceptFromJson(makeJson({
72
+ 'gl:partitiveRelations': [{
73
+ 'gl:comprehensive': { 'gl:source': 'urn:test', 'gl:id': '1.3' },
74
+ 'gl:hasPartitive': [
75
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
76
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.5' } },
77
+ ],
78
+ 'gl:hasPlurality': {
79
+ 'gl:isShared': true,
80
+ 'gl:isUncertain': true,
81
+ 'gl:sharedType': { 'gl:source': 'urn:test', 'gl:id': '1.99' },
82
+ },
83
+ }],
84
+ }));
85
+ const p = c.partitiveRelations[0].plurality;
86
+ expect(p).not.toBeNull();
87
+ expect(p?.isShared).toBe(true);
88
+ expect(p?.isUncertain).toBe(true);
89
+ expect(p?.sharedType?.id).toBe('1.99');
90
+ });
91
+
92
+ it('parses criterion as localized hash', () => {
93
+ const c = conceptFromJson(makeJson({
94
+ 'gl:partitiveRelations': [{
95
+ 'gl:comprehensive': { 'gl:source': 'urn:test', 'gl:id': '1.3' },
96
+ 'gl:hasPartitive': [
97
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
98
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.5' } },
99
+ ],
100
+ 'gl:criterion': { eng: 'physical structure', fra: 'structure physique' },
101
+ }],
102
+ }));
103
+ expect(c.partitiveRelations[0].criterion?.eng).toBe('physical structure');
104
+ expect(c.partitiveRelations[0].criterion?.fra).toBe('structure physique');
105
+ });
106
+
107
+ it('parses criterion as plain string (normalized to default)', () => {
108
+ const c = conceptFromJson(makeJson({
109
+ 'gl:partitiveRelations': [{
110
+ 'gl:comprehensive': { 'gl:source': 'urn:test', 'gl:id': '1.3' },
111
+ 'gl:hasPartitive': [
112
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
113
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.5' } },
114
+ ],
115
+ 'gl:criterion': 'a bare criterion string',
116
+ }],
117
+ }));
118
+ expect(c.partitiveRelations[0].criterion?.default).toBe('a bare criterion string');
119
+ });
120
+
121
+ it('skips relation with no comprehensive', () => {
122
+ const c = conceptFromJson(makeJson({
123
+ 'gl:partitiveRelations': [{
124
+ 'gl:hasPartitive': [
125
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
126
+ ],
127
+ }],
128
+ }));
129
+ expect(c.partitiveRelations).toEqual([]);
130
+ });
131
+
132
+ it('skips relation with < 2 valid partitives', () => {
133
+ const c = conceptFromJson(makeJson({
134
+ 'gl:partitiveRelations': [{
135
+ 'gl:comprehensive': { 'gl:source': 'urn:test', 'gl:id': '1.3' },
136
+ 'gl:hasPartitive': [
137
+ { 'gl:ref': { 'gl:source': 'urn:test', 'gl:id': '1.4' } },
138
+ // Single member — PartitiveRelation constructor throws
139
+ ],
140
+ }],
141
+ }));
142
+ // The constructor throws before we catch it, so the relation is dropped
143
+ // via the runtime error path. conceptFromJson should not propagate.
144
+ expect(c.partitiveRelations).toEqual([]);
145
+ });
146
+ });
@@ -164,6 +164,25 @@ interface JsonLdLocalizedConcept {
164
164
  'gl:reviewDecisionNotes'?: string;
165
165
  }
166
166
 
167
+ interface JsonLdPartitiveMember {
168
+ 'gl:ref'?: JsonLdRef;
169
+ 'gl:certainty'?: string;
170
+ }
171
+
172
+ interface JsonLdTypeSharedPlurality {
173
+ 'gl:isShared'?: boolean;
174
+ 'gl:isUncertain'?: boolean;
175
+ 'gl:sharedType'?: JsonLdRef;
176
+ }
177
+
178
+ interface JsonLdPartitiveRelation {
179
+ 'gl:comprehensive'?: JsonLdRef;
180
+ 'gl:hasPartitive'?: JsonLdPartitiveMember[];
181
+ 'gl:completeness'?: string;
182
+ 'gl:hasPlurality'?: JsonLdTypeSharedPlurality;
183
+ 'gl:criterion'?: Record<string, string> | string;
184
+ }
185
+
167
186
  interface JsonLdConcept {
168
187
  '@type'?: string;
169
188
  '@id'?: string;
@@ -171,6 +190,7 @@ interface JsonLdConcept {
171
190
  'gl:term'?: string;
172
191
  'gl:localizedConcept'?: Record<string, JsonLdLocalizedConcept>;
173
192
  'gl:related'?: JsonLdRelated[];
193
+ 'gl:partitiveRelations'?: JsonLdPartitiveRelation[];
174
194
  'gl:tags'?: string[];
175
195
  'gl:figureRef'?: unknown[];
176
196
  'gl:tableRef'?: unknown[];
@@ -563,6 +583,51 @@ function mapLocalizedFromJsonLd(lc: JsonLdLocalizedConcept): Record<string, unkn
563
583
  return data;
564
584
  }
565
585
 
586
+ function mapPartitiveRelationFromJsonLd(r: JsonLdPartitiveRelation): Record<string, unknown> | null {
587
+ const comprehensive = r['gl:comprehensive'] ? mapRefFromJsonLd(r['gl:comprehensive']) : null;
588
+ if (!comprehensive) return null;
589
+
590
+ const partitives = (r['gl:hasPartitive'] ?? [])
591
+ .map((m): Record<string, unknown> | null => {
592
+ const ref = m['gl:ref'] ? mapRefFromJsonLd(m['gl:ref']) : null;
593
+ if (!ref) return null;
594
+ const out: Record<string, unknown> = { ref };
595
+ if (m['gl:certainty']) out.certainty = m['gl:certainty'];
596
+ return out;
597
+ })
598
+ .filter((m): m is Record<string, unknown> => m !== null);
599
+
600
+ // ISO 704 requires ≥2 partitives. Skip malformed relations rather
601
+ // than letting the constructor throw and break the whole concept.
602
+ if (partitives.length < 2) return null;
603
+
604
+ const out: Record<string, unknown> = {
605
+ comprehensive,
606
+ partitives,
607
+ };
608
+
609
+ if (r['gl:completeness']) out.completeness = r['gl:completeness'];
610
+
611
+ const plurality = r['gl:hasPlurality'];
612
+ if (plurality && typeof plurality['gl:isShared'] === 'boolean') {
613
+ const p: Record<string, unknown> = { isShared: plurality['gl:isShared'] };
614
+ if (typeof plurality['gl:isUncertain'] === 'boolean') {
615
+ p.isUncertain = plurality['gl:isUncertain'];
616
+ }
617
+ const sharedType = plurality['gl:sharedType'] ? mapRefFromJsonLd(plurality['gl:sharedType']) : null;
618
+ if (sharedType) p.sharedType = sharedType;
619
+ out.plurality = p;
620
+ }
621
+
622
+ if (r['gl:criterion']) {
623
+ out.criterion = typeof r['gl:criterion'] === 'string'
624
+ ? { default: r['gl:criterion'] }
625
+ : r['gl:criterion'];
626
+ }
627
+
628
+ return out;
629
+ }
630
+
566
631
  function conceptFromJsonLd(doc: JsonLdConcept): Concept {
567
632
  const id = String(doc['gl:identifier'] ?? doc['@id']?.split('/').pop() ?? '');
568
633
  const localizations: Record<string, unknown> = {};
@@ -575,6 +640,9 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
575
640
  }
576
641
 
577
642
  const related = (doc['gl:related'] ?? []).map(mapRelatedFromJsonLd);
643
+ const partitiveRelations = (doc['gl:partitiveRelations'] ?? [])
644
+ .map(mapPartitiveRelationFromJsonLd)
645
+ .filter((r): r is Record<string, unknown> => r !== null);
578
646
  const tags = Array.isArray(doc['gl:tags']) ? [...doc['gl:tags']] : [];
579
647
 
580
648
  const concept = Concept.fromJSON({
@@ -583,6 +651,7 @@ function conceptFromJsonLd(doc: JsonLdConcept): Concept {
583
651
  uri: doc['@id'] ?? null,
584
652
  localizations,
585
653
  related,
654
+ partitive_relations: partitiveRelations,
586
655
  tags,
587
656
  figures: normalizeEntityRefs(doc['gl:figureRef']),
588
657
  tables: normalizeEntityRefs(doc['gl:tableRef']),
@@ -0,0 +1,230 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * PartitiveRelationDiagram — renders a single PartitiveRelation as an
4
+ * ISO 704 rake/bracket diagram in SVG.
5
+ *
6
+ * Layout (looking top-down):
7
+ *
8
+ * [comprehensive]
9
+ * │
10
+ * │ ← vertical stem
11
+ * ┌───────────────┴───────────────┐ ← horizontal spine
12
+ * │ │ │
13
+ * │ │ │ ← perpendicular drops
14
+ * [part1] [part2] [part3]
15
+ *
16
+ * Line conventions per ISO 704:
17
+ * - default : single solid line
18
+ * - plurality.isShared && !isUncertain : close-set DOUBLE solid lines
19
+ * on stem + spine (drops stay single)
20
+ * - plurality.isShared && isUncertain : one solid + one DASHED line
21
+ * - completeness === 'partial' : spine extends past the last
22
+ * tooth (continued backline —
23
+ * "more exist but aren't shown")
24
+ *
25
+ * The diagram is pure SVG — no JS layout engine. Coordinates are
26
+ * computed from the partitive count.
27
+ */
28
+ import { computed } from 'vue';
29
+ import type { TypeSharedPluralityWire } from '../adapters/types';
30
+
31
+ export interface PartitiveMemberLabeled {
32
+ uri: string;
33
+ label: string;
34
+ certainty: 'confirmed' | 'possible';
35
+ }
36
+
37
+ const props = withDefaults(defineProps<{
38
+ comprehensiveLabel: string;
39
+ partitives: PartitiveMemberLabeled[];
40
+ completeness: 'complete' | 'partial';
41
+ plurality: TypeSharedPluralityWire | null;
42
+ criterion?: string | null;
43
+ }>(), {
44
+ criterion: null,
45
+ });
46
+
47
+ const emit = defineEmits<{
48
+ (e: 'navigate', uri: string): void;
49
+ }>();
50
+
51
+ // Layout constants — chosen for readability at 100% zoom.
52
+ const NODE_W = 120;
53
+ const NODE_H = 32;
54
+ const STEM_LEN = 28;
55
+ const SPINE_TO_NODE = 32;
56
+ const GAP_X = 16;
57
+ const PADDING_X = 24;
58
+ const PADDING_Y = 16;
59
+ const DOUBLE_GAP = 3; // gap between the two lines of a close-set double
60
+ const PARTIAL_TAIL = 32; // how far the spine extends past the last tooth for partial
61
+ const DASH_PATTERN = '4 3';
62
+
63
+ type LineVariant = 'single' | 'double-solid' | 'solid-plus-dashed';
64
+
65
+ const lineVariant = computed<LineVariant>(() => {
66
+ if (!props.plurality?.isShared) return 'single';
67
+ return props.plurality.isUncertain ? 'solid-plus-dashed' : 'double-solid';
68
+ });
69
+
70
+ const layout = computed(() => {
71
+ const n = props.partitives.length;
72
+ const totalWidth = PADDING_X * 2 + n * NODE_W + (n - 1) * GAP_X;
73
+ const totalHeight = PADDING_Y * 2 + NODE_H + STEM_LEN + SPINE_TO_NODE + NODE_H;
74
+
75
+ const centerX = totalWidth / 2;
76
+ const compY = PADDING_Y + NODE_H / 2;
77
+ const spineY = compY + NODE_H / 2 + STEM_LEN;
78
+ const partY = spineY + SPINE_TO_NODE;
79
+
80
+ const partitiveSlots = props.partitives.map((member, i) => {
81
+ const x = PADDING_X + NODE_W / 2 + i * (NODE_W + GAP_X);
82
+ return { member, x };
83
+ });
84
+
85
+ const leftX = partitiveSlots[0]?.x ?? centerX;
86
+ const lastX = partitiveSlots[n - 1]?.x ?? centerX;
87
+ const spineEndX = props.completeness === 'partial'
88
+ ? lastX + PARTIAL_TAIL
89
+ : lastX;
90
+
91
+ return {
92
+ totalWidth,
93
+ totalHeight,
94
+ centerX,
95
+ compY,
96
+ spineY,
97
+ partY,
98
+ partitiveSlots,
99
+ leftX,
100
+ spineEndX,
101
+ };
102
+ });
103
+
104
+ function strokeAttr(variant: LineVariant, opacity = 1) {
105
+ if (variant === 'solid-plus-dashed') {
106
+ return { stroke: 'currentColor', 'stroke-width': 1.4, 'stroke-dasharray': DASH_PATTERN, opacity };
107
+ }
108
+ return { stroke: 'currentColor', 'stroke-width': 1.4, opacity };
109
+ }
110
+
111
+ function emitNav(uri: string) {
112
+ emit('navigate', uri);
113
+ }
114
+ </script>
115
+
116
+ <template>
117
+ <svg
118
+ :viewBox="`0 0 ${layout.totalWidth} ${layout.totalHeight}`"
119
+ width="100%"
120
+ :style="{ maxWidth: `${layout.totalWidth}px` }"
121
+ preserveAspectRatio="xMidYMid meet"
122
+ class="partitive-rake text-emerald-700 dark:text-emerald-300"
123
+ role="img"
124
+ aria-label="Partitive relation diagram"
125
+ >
126
+ <!-- Comprehensive label -->
127
+ <g>
128
+ <rect
129
+ :x="layout.centerX - NODE_W / 2"
130
+ :y="layout.compY - NODE_H / 2"
131
+ :width="NODE_W"
132
+ :height="NODE_H"
133
+ rx="6"
134
+ class="fill-emerald-50 dark:fill-emerald-900/30 stroke-current"
135
+ stroke-width="1.2"
136
+ />
137
+ <text
138
+ :x="layout.centerX"
139
+ :y="layout.compY"
140
+ text-anchor="middle"
141
+ dominant-baseline="middle"
142
+ class="text-[11px] fill-emerald-900 dark:fill-emerald-100"
143
+ >{{ comprehensiveLabel }}</text>
144
+ </g>
145
+
146
+ <!-- Criterion italic under the comprehensive -->
147
+ <text
148
+ v-if="criterion"
149
+ :x="layout.centerX"
150
+ :y="layout.compY + NODE_H / 2 + 10"
151
+ text-anchor="middle"
152
+ class="text-[9px] italic fill-emerald-800 dark:fill-emerald-200/80"
153
+ >{{ criterion }}</text>
154
+
155
+ <!-- Stem (vertical from comprehensive to spine) -->
156
+ <line
157
+ v-if="lineVariant === 'single'"
158
+ :x1="layout.centerX" :y1="layout.compY + NODE_H / 2"
159
+ :x2="layout.centerX" :y2="layout.spineY"
160
+ v-bind="strokeAttr('single')"
161
+ />
162
+ <template v-else>
163
+ <line
164
+ :x1="layout.centerX - DOUBLE_GAP / 2" :y1="layout.compY + NODE_H / 2"
165
+ :x2="layout.centerX - DOUBLE_GAP / 2" :y2="layout.spineY"
166
+ v-bind="strokeAttr('double-solid')"
167
+ />
168
+ <line
169
+ :x1="layout.centerX + DOUBLE_GAP / 2" :y1="layout.compY + NODE_H / 2"
170
+ :x2="layout.centerX + DOUBLE_GAP / 2" :y2="layout.spineY"
171
+ v-bind="strokeAttr(lineVariant === 'solid-plus-dashed' ? 'solid-plus-dashed' : 'double-solid')"
172
+ />
173
+ </template>
174
+
175
+ <!-- Spine (horizontal, from leftmost tooth to rightmost tooth or extended) -->
176
+ <line
177
+ v-if="lineVariant === 'single'"
178
+ :x1="layout.leftX" :y1="layout.spineY"
179
+ :x2="layout.spineEndX" :y2="layout.spineY"
180
+ v-bind="strokeAttr('single')"
181
+ />
182
+ <template v-else>
183
+ <line
184
+ :x1="layout.leftX" :y1="layout.spineY - DOUBLE_GAP / 2"
185
+ :x2="layout.spineEndX" :y2="layout.spineY - DOUBLE_GAP / 2"
186
+ v-bind="strokeAttr('double-solid')"
187
+ />
188
+ <line
189
+ :x1="layout.leftX" :y1="layout.spineY + DOUBLE_GAP / 2"
190
+ :x2="layout.spineEndX" :y2="layout.spineY + DOUBLE_GAP / 2"
191
+ v-bind="strokeAttr(lineVariant === 'solid-plus-dashed' ? 'solid-plus-dashed' : 'double-solid')"
192
+ />
193
+ </template>
194
+
195
+ <!-- Drops + partitive nodes -->
196
+ <g v-for="(slot, i) in layout.partitiveSlots" :key="i">
197
+ <!-- drop from spine to node (always single line per ISO 704) -->
198
+ <line
199
+ :x1="slot.x" :y1="layout.spineY"
200
+ :x2="slot.x" :y2="layout.partY - NODE_H / 2"
201
+ v-bind="strokeAttr('single', slot.member.certainty === 'possible' ? 0.5 : 1)"
202
+ :stroke-dasharray="slot.member.certainty === 'possible' ? DASH_PATTERN : undefined"
203
+ />
204
+ <!-- node -->
205
+ <g class="cursor-pointer" @click="emitNav(slot.member.uri)">
206
+ <rect
207
+ :x="slot.x - NODE_W / 2"
208
+ :y="layout.partY - NODE_H / 2"
209
+ :width="NODE_W"
210
+ :height="NODE_H"
211
+ rx="6"
212
+ :class="slot.member.certainty === 'possible'
213
+ ? 'fill-emerald-50/60 dark:fill-emerald-900/20 stroke-current'
214
+ : 'fill-emerald-50 dark:fill-emerald-900/30 stroke-current'"
215
+ :stroke-dasharray="slot.member.certainty === 'possible' ? DASH_PATTERN : undefined"
216
+ stroke-width="1.2"
217
+ />
218
+ <text
219
+ :x="slot.x"
220
+ :y="layout.partY"
221
+ text-anchor="middle"
222
+ dominant-baseline="middle"
223
+ :class="slot.member.certainty === 'possible'
224
+ ? 'text-[11px] fill-emerald-700/70 dark:fill-emerald-200/60 italic'
225
+ : 'text-[11px] fill-emerald-900 dark:fill-emerald-100'"
226
+ >{{ slot.member.label }}</text>
227
+ </g>
228
+ </g>
229
+ </svg>
230
+ </template>
@@ -1,17 +1,15 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
3
  * PartitiveRelationList — renders one-to-many partitive decompositions
4
- * as grouped cards. Each relation shows:
5
- * - the comprehensive (whole) concept as the card header
6
- * - each partitive member as an indented child with a certainty badge
4
+ * as ISO 704 rake diagrams. Each relation shows:
7
5
  * - completeness badge (complete / partial)
8
6
  * - plurality badge (shared / uncertain / sharedType) when present
9
- * - criterion as italic text under the title
7
+ * - criterion as italic text under the header
8
+ * - the rake diagram itself (PartitiveRelationDiagram)
10
9
  *
11
- * Independent of RelationshipList.vue, which renders binary edges.
12
- * Both consume the same stores but render in separate sections.
13
- *
14
- * v2 shape per concept-model/TODO.partitive-relation-v2.
10
+ * The diagram is the primary renderer. Badges in the header communicate
11
+ * the metadata that the diagram's line styles also encode (so users who
12
+ * can't perceive the visual difference still get the info).
15
13
  */
16
14
  import type { PartitiveRelationWire, Manifest } from '../adapters/types';
17
15
  import { useRouter } from 'vue-router';
@@ -19,10 +17,12 @@ import { useVocabularyStore } from '../stores/vocabulary';
19
17
  import { getFactory } from '../adapters/factory';
20
18
  import { useI18n } from '../i18n';
21
19
  import {
22
- partitiveRelationStyle,
23
20
  completenessLabel,
24
21
  certaintyLabel,
25
22
  } from '../utils/partitive-relation-styling';
23
+ import PartitiveRelationDiagram, {
24
+ type PartitiveMemberLabeled,
25
+ } from './PartitiveRelationDiagram.vue';
26
26
 
27
27
  const props = defineProps<{
28
28
  relations: PartitiveRelationWire[];
@@ -30,10 +30,6 @@ const props = defineProps<{
30
30
  registerId: string;
31
31
  }>();
32
32
 
33
- const emit = defineEmits<{
34
- (e: 'navigate', registerId: string, conceptId: string): void;
35
- }>();
36
-
37
33
  const router = useRouter();
38
34
  const store = useVocabularyStore();
39
35
  const factory = getFactory();
@@ -78,6 +74,14 @@ function pluralityBadge(plurality: NonNullable<PartitiveRelationWire['plurality'
78
74
  }
79
75
  return label;
80
76
  }
77
+
78
+ function labeledMembers(rel: PartitiveRelationWire): PartitiveMemberLabeled[] {
79
+ return rel.partitives.map(m => ({
80
+ uri: m.uri,
81
+ label: labelFor(m.uri),
82
+ certainty: m.certainty,
83
+ }));
84
+ }
81
85
  </script>
82
86
 
83
87
  <template>
@@ -115,25 +119,17 @@ function pluralityBadge(plurality: NonNullable<PartitiveRelationWire['plurality'
115
119
  >
116
120
  {{ criterionText(rel.criterion) }}
117
121
  </div>
118
- <ul class="divide-y divide-emerald-100 dark:divide-emerald-900/30">
119
- <li v-for="(member, j) in rel.partitives" :key="j">
120
- <button
121
- @click="navigate(member.uri)"
122
- class="block w-full text-left px-3 py-1.5 text-sm hover:bg-emerald-100 dark:hover:bg-emerald-900/30 text-emerald-900 dark:text-emerald-100"
123
- :class="{ 'opacity-60': member.certainty === 'possible' }"
124
- >
125
- <span class="text-emerald-600 dark:text-emerald-400 mr-2">└</span>
126
- {{ labelFor(member.uri) }}
127
- <span
128
- v-if="member.certainty === 'possible'"
129
- class="ml-2 text-[10px] font-mono px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/50 text-amber-800 dark:text-amber-200 uppercase"
130
- :title="'plurality uncertain (ISO 704 broken line)'"
131
- >
132
- {{ certaintyLabel(member.certainty) }}
133
- </span>
134
- </button>
135
- </li>
136
- </ul>
122
+ <!-- Rake diagram (ISO 704 partitive-relation convention) -->
123
+ <div class="px-3 py-3 overflow-x-auto">
124
+ <PartitiveRelationDiagram
125
+ :comprehensive-label="labelFor(rel.comprehensive)"
126
+ :partitives="labeledMembers(rel)"
127
+ :completeness="rel.completeness"
128
+ :plurality="rel.plurality"
129
+ :criterion="null"
130
+ @navigate="navigate"
131
+ />
132
+ </div>
137
133
  </div>
138
134
  </div>
139
135
  </template>
package/src/main.ts CHANGED
@@ -3,7 +3,7 @@ import { createPinia } from 'pinia';
3
3
  import App from './App.vue';
4
4
  import router from './router';
5
5
  import { vMath } from './directives/v-math';
6
- import './style.css';
6
+ import './styles/tailwind.css';
7
7
 
8
8
  const app = createApp(App);
9
9
  app.use(createPinia());
package/src/style.css CHANGED
@@ -492,7 +492,7 @@
492
492
 
493
493
  /* Section labels brighter in dark mode */
494
494
  .dark .section-label {
495
- color: theme('colors.ink.300') !important;
495
+ color: var(--color-ink-300) !important;
496
496
  }
497
497
 
498
498
  /* Scrollbar hide utility */