@glossarist/concept-browser 0.7.133 → 0.7.134

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.133",
3
+ "version": "0.7.134",
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": {
@@ -167,3 +167,117 @@ describe('drawGenericPipes — pipe-and-thread contract', () => {
167
167
  expect(svg.querySelectorAll('line.pipe-seg').length).toBe(0);
168
168
  });
169
169
  });
170
+
171
+ describe('drawGenericPipes — external + ellipsis ghosts (concept-model PR #91)', () => {
172
+ const pos = new Map<string, { x: number; y: number }>([
173
+ ['vim-2012/1.9', { x: 0, y: 0 }],
174
+ ['vim-2012/1.15', { x: 100, y: -50 }],
175
+ ]);
176
+
177
+ function makeGhostRelation(): GenericRelationWire {
178
+ return {
179
+ source: 'https://example.org/vim-2012/concept/1.9',
180
+ comprehensive: 'https://example.org/vim-2012/concept/1.9',
181
+ completeness: 'complete',
182
+ criterion: { eng: 'by system membership' },
183
+ register: 'vim-2012',
184
+ members: [
185
+ { uri: 'https://example.org/vim-2012/concept/1.15', presence: 'required', count: 'at_least_one',
186
+ delimitingCharacteristic: { eng: 'off-system measurement unit' } },
187
+ { uri: 'urn:gloss:ext:in-system measurement unit', presence: 'optional', count: 'any',
188
+ external: true, text: 'in-system measurement unit',
189
+ delimitingCharacteristic: { eng: 'in-system measurement unit' } },
190
+ ],
191
+ };
192
+ }
193
+
194
+ function makeEllipsisRelation(): GenericRelationWire {
195
+ return {
196
+ source: 'https://example.org/vim-2012/concept/1.9',
197
+ comprehensive: 'https://example.org/vim-2012/concept/1.9',
198
+ completeness: 'partial',
199
+ criterion: { eng: 'by epistemic status' },
200
+ register: 'vim-2012',
201
+ members: [
202
+ { uri: 'https://example.org/vim-2012/concept/1.15', presence: 'required', count: 'at_least_one',
203
+ delimitingCharacteristic: { eng: 'x' } },
204
+ { uri: 'urn:gloss:ellipsis', presence: 'required', count: 'exactly_one', ellipsis: true },
205
+ ],
206
+ };
207
+ }
208
+
209
+ it('renders an external member as a dashed pill with parenthesized text', () => {
210
+ const svg = makeSvg();
211
+ drawGenericPipes(svg, pos, {
212
+ isDark: false,
213
+ color: '#b45309',
214
+ isMuted: false,
215
+ relations: [makeGhostRelation()],
216
+ locale: 'eng',
217
+ });
218
+ const pill = svg.querySelector('rect.pipe-ghost-external');
219
+ expect(pill).toBeDefined();
220
+ expect(pill!.getAttribute('stroke-dasharray')).toBe('3 2');
221
+ const label = svg.querySelector('text.pipe-ghost-external-label');
222
+ expect(label).toBeDefined();
223
+ expect(label!.textContent).toBe('(in-system measurement unit)');
224
+ });
225
+
226
+ it('renders an ellipsis member as a muted ... ghost', () => {
227
+ const svg = makeSvg();
228
+ drawGenericPipes(svg, pos, {
229
+ isDark: false,
230
+ color: '#b45309',
231
+ isMuted: false,
232
+ relations: [makeEllipsisRelation()],
233
+ locale: 'eng',
234
+ });
235
+ const ell = svg.querySelector('text.pipe-ghost-ellipsis');
236
+ expect(ell).toBeDefined();
237
+ expect(ell!.textContent).toBe('...');
238
+ });
239
+
240
+ it('keeps a 1-resolved + 1-ghost rake alive (ghosts count as members)', () => {
241
+ const svg = makeSvg();
242
+ drawGenericPipes(svg, pos, {
243
+ isDark: false,
244
+ color: '#b45309',
245
+ isMuted: false,
246
+ relations: [makeGhostRelation()],
247
+ locale: 'eng',
248
+ });
249
+ /* 1 resolved member + 1 ghost = 2 layout members → 1 pipe + 2 threads. */
250
+ expect(svg.querySelectorAll('line.pipe-seg').length).toBe(3);
251
+ });
252
+
253
+ it('draws ghost threads dashed so they read as non-node placeholders', () => {
254
+ const svg = makeSvg();
255
+ drawGenericPipes(svg, pos, {
256
+ isDark: false,
257
+ color: '#b45309',
258
+ isMuted: false,
259
+ relations: [makeEllipsisRelation()],
260
+ locale: 'eng',
261
+ });
262
+ const dashed = Array.from(svg.querySelectorAll('line.pipe-seg'))
263
+ .filter(l => l.getAttribute('stroke-dasharray') === '3 3');
264
+ expect(dashed.length).toBe(1);
265
+ });
266
+
267
+ it('drops a rake whose members are ALL ghosts with no positioned anchor', () => {
268
+ const svg = makeSvg();
269
+ const emptyPos = new Map<string, { x: number; y: number }>([
270
+ ['vim-2012/1.9', { x: 0, y: 0 }],
271
+ ]);
272
+ drawGenericPipes(svg, emptyPos, {
273
+ isDark: false,
274
+ color: '#b45309',
275
+ isMuted: false,
276
+ relations: [makeGhostRelation()],
277
+ locale: 'eng',
278
+ });
279
+ /* 1.15 not in pos → zero positioned members → layout collapses onto
280
+ comp; ghosts have no anchor distinct from comp, so nothing draws. */
281
+ expect(svg.querySelectorAll('line.pipe-seg').length).toBe(0);
282
+ });
283
+ });
@@ -60,6 +60,6 @@ describe('GL wire-key SSOT', () => {
60
60
  // The point of this test: when adding a key, you remember to update
61
61
  // the count; when removing, you notice the test fails. Catches
62
62
  // accidental drift in either direction.
63
- expect(Object.keys(GL).length).toBe(91);
63
+ expect(Object.keys(GL).length).toBe(93);
64
64
  });
65
65
  });
@@ -43,6 +43,8 @@ export interface JsonLdRef {
43
43
  'gl:id'?: string;
44
44
  'gl:version'?: string;
45
45
  'gl:text'?: string;
46
+ 'gl:external'?: boolean;
47
+ 'gl:ellipsis'?: boolean;
46
48
  source?: string;
47
49
  id?: string;
48
50
  version?: string;
@@ -298,7 +298,9 @@ function mapRefFromJsonLd(rawRef: JsonLdRef | string | undefined): Record<string
298
298
  refObj.id = rawRef[GL.LOCAL_ID] ?? rawRef.id;
299
299
  refObj.version = rawRef[GL.VERSION] ?? rawRef.version;
300
300
  if (rawRef[GL.TEXT]) refObj.text = rawRef[GL.TEXT];
301
- return (refObj.source ?? refObj.id ?? refObj.version ?? refObj.text) != null
301
+ if (rawRef[GL.EXTERNAL] === true) refObj.external = true;
302
+ if (rawRef[GL.ELLIPSIS] === true) refObj.ellipsis = true;
303
+ return (refObj.source ?? refObj.id ?? refObj.version ?? refObj.text ?? refObj.external ?? refObj.ellipsis) != null
302
304
  ? refObj : null;
303
305
  }
304
306
 
@@ -204,6 +204,14 @@ export interface GenericMemberWire {
204
204
  * distinguishes this specific concept from its coordinate concepts.
205
205
  * Required on every GenericMember. LocalizedString shape. */
206
206
  delimitingCharacteristic: Record<string, string>;
207
+ /** Inline external concept (concept-model PR #91). No dataset node —
208
+ * rendered as a dashed `(text)` ghost, never resolved to a URI. */
209
+ external?: boolean;
210
+ /** Author-acknowledged gap (concept-model PR #91). Rendered as a
211
+ * muted `...` ghost. */
212
+ ellipsis?: boolean;
213
+ /** Display text for external members (the parenthetical label). */
214
+ text?: string;
207
215
  }
208
216
 
209
217
  export interface GraphNode {
@@ -108,6 +108,12 @@ export const GL = {
108
108
  VERSION: 'gl:version',
109
109
  TEXT: 'gl:text',
110
110
 
111
+ // ConceptRef external/ellipsis flags (concept-model PR #91).
112
+ // external = parenthetical (ISO 704:2022 §5.5.4.3.1); renders as `(text)`.
113
+ // ellipsis = author-acknowledged gap; renders as `...`.
114
+ EXTERNAL: 'gl:external',
115
+ ELLIPSIS: 'gl:ellipsis',
116
+
111
117
  // Locally-scoped id within source/origin/ref (NOT JSON-LD's @id)
112
118
  LOCAL_ID: 'gl:id',
113
119
 
@@ -80,7 +80,16 @@ export function drawGenericPipes(
80
80
  if (!compPos) continue;
81
81
 
82
82
  const memberPositions: PipeLayoutMember[] = [];
83
+ const ghostMembers: Array<{ member: GenericRelationWire['members'][number]; pos: NodePos }> = [];
83
84
  for (const m of rel.members) {
85
+ /* Ghost members (external / ellipsis, concept-model PR #91) carry
86
+ synthetic urn:gloss:* URIs that never resolve to a node — they
87
+ get virtual positions on an arc beyond the middle node and are
88
+ drawn as dashed ghosts after the layout. */
89
+ if (m.ellipsis === true || m.external === true) {
90
+ ghostMembers.push({ member: m, pos: { x: 0, y: 0 } });
91
+ continue;
92
+ }
84
93
  const parsed = UriRouter.parseUri(m.uri);
85
94
  if (!parsed) continue;
86
95
  const nid = `${parsed.registerId}/${parsed.conceptId}`;
@@ -89,7 +98,31 @@ export function drawGenericPipes(
89
98
  memberPositions.push({ pos: p, delimitingCharacteristic: m.delimitingCharacteristic });
90
99
  }
91
100
 
92
- const layout = computePipeLayout(compPos, memberPositions);
101
+ /* Ghosts need a middle-node estimate to fan around. With ≥2
102
+ positioned members the layout gives it directly; with exactly 1
103
+ use the comp→member midpoint (computePipeLayout requires ≥2 and
104
+ would return null). Zero positioned members means the ghosts have
105
+ no anchor — drop the rake. */
106
+ if (memberPositions.length === 0 && ghostMembers.length > 0) continue;
107
+ let provisionalMiddle: NodePos | null = null;
108
+ if (memberPositions.length >= 2) {
109
+ const l = computePipeLayout(compPos, memberPositions);
110
+ provisionalMiddle = l ? l.middleNode : null;
111
+ } else if (memberPositions.length === 1) {
112
+ provisionalMiddle = {
113
+ x: (compPos.x + memberPositions[0].pos.x) / 2,
114
+ y: (compPos.y + memberPositions[0].pos.y) / 2,
115
+ };
116
+ }
117
+ if (!provisionalMiddle) continue;
118
+ assignGhostPositions(provisionalMiddle, compPos, memberPositions, ghostMembers);
119
+
120
+ const allPositions = [
121
+ ...memberPositions,
122
+ ...ghostMembers.map(g => ({ pos: g.pos, delimitingCharacteristic: g.member.delimitingCharacteristic })),
123
+ ];
124
+
125
+ const layout = computePipeLayout(compPos, allPositions);
93
126
  if (!layout) continue;
94
127
 
95
128
  /* 1. Thick parent pipe: comp → middle node. The characteristic
@@ -98,9 +131,25 @@ export function drawGenericPipes(
98
131
 
99
132
  /* 2. Thin threads: middle → each member. No per-thread labels —
100
133
  the characteristic is a single hyperedge-level field shared
101
- by all members, rendered once on the pipe body. */
102
- for (const thread of layout.threads) {
134
+ by all members, rendered once on the pipe body.
135
+ Ghost members are excluded here (step 2b draws their dashed
136
+ threads separately) — layout.threads preserves the
137
+ allPositions order: positioned members first, ghosts last. */
138
+ layout.threads.forEach((thread, i) => {
139
+ if (i >= memberPositions.length) return;
103
140
  drawSegment(svg, opts.color, thread.start, thread.end, THREAD_WIDTH);
141
+ });
142
+
143
+ /* 2b. Ghost members — external concepts and ellipsis gaps. Dashed
144
+ threads + dashed pills so they read as "present but not a
145
+ node of this dataset" (ISO 704:2022 §5.5.4.3.1). */
146
+ for (const ghost of ghostMembers) {
147
+ drawSegment(svg, opts.color, layout.middleNode, ghost.pos, THREAD_WIDTH, { dashed: true, opacity: 0.55 });
148
+ if (ghost.member.ellipsis === true) {
149
+ drawEllipsisGhost(svg, ghost.pos, opts.color);
150
+ } else {
151
+ drawExternalGhost(svg, ghost.pos, ghost.member.text ?? '', opts.color, opts.isDark);
152
+ }
104
153
  }
105
154
 
106
155
  /* 3. Square junction at the middle node (distinct from the rake's
@@ -129,7 +178,7 @@ export function drawGenericPipes(
129
178
  }
130
179
  }
131
180
 
132
- function drawSegment(svg: SVGSVGElement, color: string, from: NodePos, to: NodePos, width: number): void {
181
+ function drawSegment(svg: SVGSVGElement, color: string, from: NodePos, to: NodePos, width: number, style?: { dashed?: boolean; opacity?: number }): void {
133
182
  const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
134
183
  line.setAttribute('class', 'pipe-seg');
135
184
  line.setAttribute('x1', String(from.x));
@@ -139,10 +188,93 @@ function drawSegment(svg: SVGSVGElement, color: string, from: NodePos, to: NodeP
139
188
  line.setAttribute('stroke', color);
140
189
  line.setAttribute('stroke-width', String(width));
141
190
  line.setAttribute('stroke-linecap', 'round');
142
- line.setAttribute('opacity', '0.9');
191
+ line.setAttribute('opacity', String(style?.opacity ?? 0.9));
192
+ if (style?.dashed) line.setAttribute('stroke-dasharray', '3 3');
143
193
  svg.appendChild(line);
144
194
  }
145
195
 
196
+ /**
197
+ * Place ghost members (external / ellipsis) on an arc beyond the middle
198
+ * node, on the side away from the comprehensive, fanned to match the
199
+ * spread of the positioned members. Pure 2D — ghosts never enter the
200
+ * 3D force layout.
201
+ */
202
+ function assignGhostPositions(
203
+ middleNode: NodePos,
204
+ compPos: NodePos,
205
+ positioned: readonly { pos: NodePos }[],
206
+ ghosts: Array<{ pos: NodePos }>,
207
+ ): void {
208
+ if (ghosts.length === 0) return;
209
+ /* Fan radius: a bit beyond the average positioned-member distance
210
+ from the middle node so ghosts sit outside the resolved set. */
211
+ const avgDist = positioned.length > 0
212
+ ? positioned.reduce((s, m) => s + Math.hypot(m.pos.x - middleNode.x, m.pos.y - middleNode.y), 0) / positioned.length
213
+ : 40;
214
+ const radius = Math.max(avgDist, 40);
215
+ /* Base direction: away from the comprehensive. */
216
+ const awayX = middleNode.x - compPos.x;
217
+ const awayY = middleNode.y - compPos.y;
218
+ const awayLen = Math.hypot(awayX, awayY) || 1;
219
+ const baseAngle = Math.atan2(awayY / awayLen, awayX / awayLen);
220
+ const spread = 0.5; /* radians between consecutive ghosts */
221
+ ghosts.forEach((g, i) => {
222
+ const offset = ghosts.length === 1 ? 0 : (i - (ghosts.length - 1) / 2) * spread;
223
+ const a = baseAngle + offset;
224
+ g.pos.x = middleNode.x + Math.cos(a) * radius;
225
+ g.pos.y = middleNode.y + Math.sin(a) * radius;
226
+ });
227
+ }
228
+
229
+ /** Ellipsis ghost: a muted "..." — the author knew more members exist. */
230
+ function drawEllipsisGhost(svg: SVGSVGElement, at: NodePos, color: string): void {
231
+ const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
232
+ text.setAttribute('class', 'pipe-ghost-ellipsis');
233
+ text.setAttribute('x', String(at.x));
234
+ text.setAttribute('y', String(at.y + 3));
235
+ text.setAttribute('text-anchor', 'middle');
236
+ text.setAttribute('fill', color);
237
+ text.setAttribute('font-size', '11');
238
+ text.setAttribute('font-family', 'JetBrains Mono, monospace');
239
+ text.setAttribute('font-weight', '600');
240
+ text.setAttribute('opacity', '0.8');
241
+ text.textContent = '...';
242
+ svg.appendChild(text);
243
+ }
244
+
245
+ /** External ghost: dashed-border pill with the parenthetical label. */
246
+ function drawExternalGhost(svg: SVGSVGElement, at: NodePos, label: string, color: string, isDark: boolean): void {
247
+ const fontSize = 9;
248
+ const text = label ? `(${label})` : '(external)';
249
+ const w = Math.max(text.length * (fontSize * 0.55) + 10, 34);
250
+ const h = 15;
251
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
252
+ rect.setAttribute('class', 'pipe-ghost-external');
253
+ rect.setAttribute('x', String(at.x - w / 2));
254
+ rect.setAttribute('y', String(at.y - h / 2));
255
+ rect.setAttribute('width', String(w));
256
+ rect.setAttribute('height', String(h));
257
+ rect.setAttribute('rx', '4');
258
+ rect.setAttribute('fill', isDark ? 'rgba(26,18,8,0.85)' : 'rgba(255,255,255,0.92)');
259
+ rect.setAttribute('stroke', color);
260
+ rect.setAttribute('stroke-width', '1');
261
+ rect.setAttribute('stroke-dasharray', '3 2');
262
+ rect.setAttribute('opacity', '0.92');
263
+ svg.appendChild(rect);
264
+
265
+ const t = document.createElementNS('http://www.w3.org/2000/svg', 'text');
266
+ t.setAttribute('class', 'pipe-ghost-external-label');
267
+ t.setAttribute('x', String(at.x));
268
+ t.setAttribute('y', String(at.y + 3));
269
+ t.setAttribute('text-anchor', 'middle');
270
+ t.setAttribute('fill', color);
271
+ t.setAttribute('font-size', String(fontSize));
272
+ t.setAttribute('font-family', 'JetBrains Mono, monospace');
273
+ t.setAttribute('font-style', 'italic');
274
+ t.textContent = text;
275
+ svg.appendChild(t);
276
+ }
277
+
146
278
  function drawSquare(svg: SVGSVGElement, cx: number, cy: number, half: number, fill: string, stroke: string): void {
147
279
  const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
148
280
  rect.setAttribute('class', 'pipe-junction');
@@ -190,7 +190,26 @@ export function useConceptEdges(
190
190
  const isGeneric = rel instanceof GenericHyperedge;
191
191
  const members: any[] = (rel?.members ?? rel?.partitives ?? [])
192
192
  .map((m: any): any | null => {
193
- const uri = resolveConceptRefUri(m?.ref);
193
+ const ref = m?.ref;
194
+ /* Ghost members (concept-model PR #91): external parenthetical
195
+ concepts and ellipsis gaps have no dataset node, so they get a
196
+ synthetic urn: URI plus flags. Renderers draw them as ghosts
197
+ without resolving the URI. */
198
+ if (ref?.ellipsis === true) {
199
+ return { uri: 'urn:gloss:ellipsis', presence: m?.presence, count: m?.count, ellipsis: true };
200
+ }
201
+ if (ref?.external === true || ref?.isExternal === true) {
202
+ const text = ref?.text ?? '';
203
+ return {
204
+ uri: `urn:gloss:ext:${text}`,
205
+ presence: m?.presence,
206
+ count: m?.count,
207
+ external: true,
208
+ text,
209
+ ...(isGeneric ? { delimitingCharacteristic: m?.delimitingCharacteristic } : {}),
210
+ };
211
+ }
212
+ const uri = resolveConceptRefUri(ref);
194
213
  if (!uri || uri === source) return null;
195
214
  const base: any = {
196
215
  uri,