kmap-term-tree 0.9.21 → 1.0.0

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 (37) hide show
  1. package/demo/index.html +124 -10
  2. package/demo/kmap.html +64 -0
  3. package/dist/src/KmapTermTree.d.ts +37 -46
  4. package/dist/src/KmapTermTree.js +641 -216
  5. package/dist/src/KmapTermTree.js.map +1 -1
  6. package/dist/src/asciimath-parser.d.ts +17 -0
  7. package/dist/src/asciimath-parser.js +472 -0
  8. package/dist/src/asciimath-parser.js.map +1 -0
  9. package/dist/src/index.d.ts +2 -0
  10. package/dist/src/index.js +2 -0
  11. package/dist/src/index.js.map +1 -1
  12. package/dist/src/katex-css.d.ts +2 -0
  13. package/dist/src/katex-css.js +7 -0
  14. package/dist/src/katex-css.js.map +1 -0
  15. package/dist/src/model.d.ts +30 -0
  16. package/dist/src/model.js +26 -0
  17. package/dist/src/model.js.map +1 -0
  18. package/dist/src/parser.js.map +1 -1
  19. package/dist/src/tokenizer.js.map +1 -1
  20. package/dist/stories/index.stories.d.ts +20 -0
  21. package/dist/stories/index.stories.js +26 -2
  22. package/dist/stories/index.stories.js.map +1 -1
  23. package/dist/test/asciimath-parser.test.d.ts +1 -0
  24. package/dist/test/asciimath-parser.test.js +45 -0
  25. package/dist/test/asciimath-parser.test.js.map +1 -0
  26. package/dist/test/kmap-term-tree.test.js +45 -8
  27. package/dist/test/kmap-term-tree.test.js.map +1 -1
  28. package/dist/tsconfig.tsbuildinfo +1 -1
  29. package/package.json +16 -50
  30. package/src/KmapTermTree.ts +675 -230
  31. package/src/asciimath-parser.ts +554 -0
  32. package/src/index.ts +2 -0
  33. package/src/katex-css.ts +7 -0
  34. package/src/model.ts +57 -0
  35. package/stories/index.stories.ts +35 -2
  36. package/test/asciimath-parser.test.ts +50 -0
  37. package/test/kmap-term-tree.test.ts +66 -7
@@ -1,279 +1,724 @@
1
- import {css, html, LitElement, PropertyValues, svg} from 'lit';
2
- import {property, state, query} from 'lit/decorators.js';
3
- import {ifDefined} from 'lit/directives/if-defined.js';
4
- import {Parser, TermNode} from "./parser.js";
5
- import {Token} from "./tokenizer.js";
6
-
7
- interface Connection {
8
- from: number,
9
- to: number,
10
- }
11
- interface Position {
12
- x: number,
13
- y: number,
14
- width: number,
15
- height: number,
16
- }
17
- interface Edge {
18
- from: Position,
19
- to: Position,
20
- }
1
+ import { css, html, LitElement, PropertyValues, svg } from 'lit';
2
+ import { property, state, query } from 'lit/decorators.js';
3
+ import katex from 'katex';
4
+ import { katexStyles } from './katex-css.js';
5
+ import { TermNode, TreeEdge } from './model.js';
6
+ import { parseAsciiMath, astToAnnotatedLatex } from './asciimath-parser.js';
7
+
21
8
  export class KmapTermTree extends LitElement {
22
- declare shadowRoot: ShadowRoot;
23
-
24
- // language=CSS
25
- static styles = css`
26
- :host {
27
- display: block;
28
- color: var(--kmap-term-tree-text-color, #000);
29
- position: relative;
30
- }
31
- .tokens, .nodes, kmap-term-tree-edges {
32
- position: absolute;
33
- }
34
- .tokens, .nodes {
35
- white-space: nowrap;
36
- }
37
- .notokens {
38
- white-space: nowrap;
39
- height: 0px;
40
- }
41
- .notokens div {
42
- display: inline-block;
43
- }
44
- kmap-term-tree-edges {
45
- width: 100%;
46
- height: 100%;
9
+ static styles = [
10
+ katexStyles,
11
+ css`
12
+ :host {
13
+ display: block;
14
+ position: relative;
15
+ font-family: var(
16
+ --kmap-term-tree-font-family,
17
+ system-ui,
18
+ -apple-system,
19
+ sans-serif
20
+ );
21
+ color: var(--kmap-term-tree-text-color, #1f2937);
22
+ user-select: none;
23
+ width: 100%;
24
+ }
25
+
26
+ .container {
27
+ position: relative;
28
+ display: flex;
29
+ flex-direction: column;
30
+ align-items: center;
31
+ width: 100%;
32
+ box-sizing: border-box;
33
+ }
34
+
35
+ .tree-canvas {
36
+ position: relative;
37
+ width: 100%;
38
+ min-height: 80px;
39
+ }
40
+
41
+ .formula-wrapper {
42
+ position: relative;
43
+ z-index: 2;
44
+ display: flex;
45
+ justify-content: center;
46
+ align-items: center;
47
+ padding-top: 4px;
48
+ font-size: var(--kmap-term-tree-formula-font-size, 1.35rem);
49
+ }
50
+
51
+ .highlight-overlay {
52
+ position: absolute;
53
+ z-index: 1;
54
+ pointer-events: none;
55
+ background-color: var(
56
+ --kmap-term-tree-highlight-color,
57
+ rgba(251, 191, 36, 0.28)
58
+ );
59
+ border: 2px solid
60
+ var(--kmap-term-tree-highlight-outline, rgba(245, 158, 11, 0.75));
61
+ border-radius: 6px;
62
+ box-sizing: border-box;
63
+ transition: left 0.12s ease-out, top 0.12s ease-out,
64
+ width 0.12s ease-out, height 0.12s ease-out;
65
+ }
66
+
67
+ .edges-svg {
68
+ position: absolute;
69
+ top: 0;
70
+ left: 0;
71
+ width: 100%;
72
+ height: 100%;
73
+ pointer-events: none;
74
+ z-index: 1;
75
+ overflow: visible;
76
+ }
77
+
78
+ .edge-path {
79
+ fill: none;
80
+ stroke: var(--kmap-term-tree-edge-color, #f59e0b);
81
+ stroke-width: 2.2;
82
+ stroke-linecap: round;
83
+ opacity: 0.85;
84
+ transition: stroke 0.2s ease, stroke-width 0.2s ease, opacity 0.2s ease;
85
+ }
86
+
87
+ .edge-path.highlighted {
88
+ stroke: var(--kmap-term-tree-highlight-edge-color, #d97706);
89
+ stroke-width: 3.2;
90
+ opacity: 1;
91
+ }
92
+
93
+ .tree-node {
94
+ position: absolute;
95
+ z-index: 2;
96
+ transform: translate(-50%, -50%);
97
+ display: inline-flex;
98
+ flex-direction: column;
99
+ align-items: center;
100
+ cursor: pointer;
101
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
102
+ }
103
+
104
+ .tree-node:hover,
105
+ .tree-node.active {
106
+ transform: translate(-50%, -50%) scale(1.15);
107
+ }
108
+
109
+ .node-badge {
110
+ display: flex;
111
+ align-items: center;
112
+ justify-content: center;
113
+ width: 28px;
114
+ height: 28px;
115
+ border-radius: 50%;
116
+ background: var(--kmap-term-tree-background-color, #ffffff);
117
+ border: 2px solid var(--kmap-term-tree-border-color, #0284c7);
118
+ color: var(--kmap-term-tree-node-text-color, #0284c7);
119
+ font-weight: 700;
120
+ font-size: 1rem;
121
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08);
122
+ box-sizing: border-box;
123
+ transition: all 0.2s ease;
124
+ }
125
+
126
+ .tree-node:hover .node-badge,
127
+ .tree-node.active .node-badge {
128
+ background: var(--kmap-term-tree-active-node-bg, #0284c7);
129
+ color: #ffffff;
130
+ border-color: var(--kmap-term-tree-active-node-border, #0284c7);
131
+ box-shadow: 0 4px 8px rgba(2, 132, 199, 0.35);
132
+ }
133
+
134
+ .node-label {
135
+ margin-top: 2px;
136
+ font-size: 0.7rem;
137
+ font-weight: 500;
138
+ color: #64748b;
139
+ white-space: nowrap;
140
+ background: rgba(255, 255, 255, 0.95);
141
+ padding: 1px 4px;
142
+ border-radius: 4px;
143
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
144
+ }
145
+
146
+ /* KaTeX Highlight */
147
+ .highlight-term {
148
+ background-color: var(
149
+ --kmap-term-tree-highlight-color,
150
+ rgba(251, 191, 36, 0.35)
151
+ ) !important;
152
+ border-radius: 4px;
153
+ outline: 2px solid
154
+ var(--kmap-term-tree-highlight-outline, rgba(245, 158, 11, 0.7));
155
+ transition: background-color 0.2s ease, outline 0.2s ease;
156
+ }
157
+
158
+ .info-bar {
159
+ margin-top: 12px;
160
+ padding: 6px 14px;
161
+ background: #f8fafc;
162
+ border: 1px solid #e2e8f0;
163
+ border-radius: 8px;
164
+ font-size: 0.88rem;
165
+ display: flex;
166
+ align-items: center;
167
+ gap: 8px;
168
+ color: #334155;
169
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
170
+ }
171
+
172
+ .info-badge {
173
+ font-weight: 700;
174
+ color: #0284c7;
175
+ background: #e0f2fe;
176
+ padding: 2px 8px;
177
+ border-radius: 4px;
178
+ }
179
+
180
+ .error-msg {
181
+ color: #dc2626;
182
+ padding: 8px;
183
+ font-size: 0.9rem;
184
+ }
185
+ `,
186
+ ];
187
+
188
+ @property({ type: String }) term?: string;
189
+ @property({ type: Number }) tension: number = 0.5;
190
+ @property({ type: Boolean }) showLabels: boolean = false;
191
+ @property({ type: Boolean }) showDetails: boolean = true;
192
+
193
+ @state() private ast?: TermNode;
194
+ @state() private error?: string;
195
+ @state() private flatNodes: TermNode[] = [];
196
+ @state() private edges: TreeEdge[] = [];
197
+ @state() private activeNodeId?: string;
198
+ @state() private activeSubtreeIds: Set<string> = new Set();
199
+ @state() private treeHeight: number = 100;
200
+ @state() private highlightRect?: {
201
+ left: number;
202
+ top: number;
203
+ width: number;
204
+ height: number;
205
+ };
206
+
207
+ @query('#formula') private formulaElement?: HTMLElement;
208
+ @query('.tree-canvas') private treeCanvasElement?: HTMLElement;
209
+
210
+ private resizeObserver?: ResizeObserver;
211
+ private lastObservedWidth = 0;
212
+ private resizeFrameId?: number;
213
+ private animationTimers: number[] = [];
214
+
215
+ connectedCallback() {
216
+ super.connectedCallback();
217
+ this.resizeObserver = new ResizeObserver(entries => {
218
+ for (const entry of entries) {
219
+ const width = entry.contentRect.width;
220
+ if (Math.abs(width - this.lastObservedWidth) > 0.5) {
221
+ this.lastObservedWidth = width;
222
+ this.triggerLayoutUpdate();
223
+ }
224
+ }
225
+ });
226
+
227
+ window.addEventListener('animationend', this.onAnimationOrTransitionEnd);
228
+ window.addEventListener('transitionend', this.onAnimationOrTransitionEnd);
229
+
230
+ if (typeof document !== 'undefined' && document.fonts) {
231
+ document.fonts.ready.then(() => {
232
+ if (this.isConnected) {
233
+ this.recalculateLayout();
234
+ }
235
+ });
47
236
  }
48
- .token {
49
- display: inline-block;
237
+ }
238
+
239
+ disconnectedCallback() {
240
+ if (this.resizeFrameId) {
241
+ cancelAnimationFrame(this.resizeFrameId);
50
242
  }
51
- .token, .node {
52
- display: inline-block;
53
- position: relative;
54
- background-color: var(--kmap-term-tree-background-color, white);
243
+ this.clearAnimationTimers();
244
+ window.removeEventListener('animationend', this.onAnimationOrTransitionEnd);
245
+ window.removeEventListener('transitionend', this.onAnimationOrTransitionEnd);
246
+ this.resizeObserver?.disconnect();
247
+ super.disconnectedCallback();
248
+ }
249
+
250
+ private onAnimationOrTransitionEnd = () => {
251
+ this.triggerLayoutUpdate();
252
+ };
253
+
254
+ private triggerLayoutUpdate() {
255
+ if (this.resizeFrameId) {
256
+ cancelAnimationFrame(this.resizeFrameId);
55
257
  }
56
- .node {
57
- display: inline-block;
58
- position: relative;
59
- border: 1px solid var(--kmap-term-tree-border-color, #005b9f);
60
- padding: 0px 4px;
61
- border-radius: 1em;
62
- margin: -1px -5px;
258
+ this.resizeFrameId = requestAnimationFrame(() => {
259
+ if (this.isConnected) {
260
+ this.recalculateLayout();
261
+ }
262
+ });
263
+ }
264
+
265
+ private watchAncestorAnimations() {
266
+ let curr: Element | null = this;
267
+ const animations: Animation[] = [];
268
+
269
+ while (curr) {
270
+ if (typeof curr.getAnimations === 'function') {
271
+ for (const anim of curr.getAnimations()) {
272
+ if (anim.playState === 'running' || anim.pending) {
273
+ animations.push(anim);
274
+ }
275
+ }
276
+ }
277
+ const root = curr.getRootNode() as ShadowRoot | Document;
278
+ if (root && 'host' in root) {
279
+ curr = (root as ShadowRoot).host;
280
+ } else {
281
+ curr = curr.parentElement;
282
+ }
63
283
  }
64
- .node[depth="0"], .node:not([depth]) {
65
- visibility: hidden;
284
+
285
+ if (animations.length > 0) {
286
+ Promise.all(animations.map(a => a.finished.catch(() => {}))).then(() => {
287
+ if (this.isConnected) {
288
+ this.recalculateLayout();
289
+ }
290
+ });
66
291
  }
67
- .node[depth="1"] {
68
- top: calc(var(--kmap-term-tree-vertical-distance, 1.5em) * 1.5);
292
+ }
293
+
294
+ private scheduleAnimationCheck() {
295
+ this.clearAnimationTimers();
296
+ this.watchAncestorAnimations();
297
+
298
+ const t1 = window.setTimeout(() => {
299
+ if (!this.isConnected) return;
300
+ this.watchAncestorAnimations();
301
+ this.recalculateLayout();
302
+ }, 50);
303
+
304
+ const t2 = window.setTimeout(() => {
305
+ if (!this.isConnected) return;
306
+ this.recalculateLayout();
307
+ }, 320);
308
+
309
+ const t3 = window.setTimeout(() => {
310
+ if (!this.isConnected) return;
311
+ this.recalculateLayout();
312
+ }, 450);
313
+
314
+ this.animationTimers.push(t1, t2, t3);
315
+ }
316
+
317
+ private clearAnimationTimers() {
318
+ for (const t of this.animationTimers) {
319
+ clearTimeout(t);
69
320
  }
70
- .node[depth="2"] {
71
- top: calc(var(--kmap-term-tree-vertical-distance, 1.5em) * 3);
321
+ this.animationTimers = [];
322
+ }
323
+
324
+ // Support slotted content: <kmap-term-tree>2a+3b</kmap-term-tree>
325
+ updateSlotted(event: Event) {
326
+ const slot = event.target as HTMLSlotElement;
327
+ const text = slot
328
+ .assignedNodes()
329
+ .map(n => n.textContent)
330
+ .join('')
331
+ .trim();
332
+ if (text && text !== this.term) {
333
+ this.term = text;
72
334
  }
73
- .node[depth="3"] {
74
- top: calc(var(--kmap-term-tree-vertical-distance, 1.5em) * 4.5);
335
+ }
336
+
337
+ protected willUpdate(changedProperties: PropertyValues) {
338
+ if (changedProperties.has('term') && this.term !== undefined) {
339
+ this.parseInput(this.term);
75
340
  }
76
- .node[depth="4"] {
77
- top: calc(var(--kmap-term-tree-vertical-distance, 1.5em) * 6);
341
+ }
342
+
343
+ private parseInput(termStr: string) {
344
+ try {
345
+ this.error = undefined;
346
+ const cleanTerm = termStr.trim();
347
+ if (!cleanTerm) {
348
+ this.ast = undefined;
349
+ this.flatNodes = [];
350
+ this.edges = [];
351
+ return;
352
+ }
353
+
354
+ this.ast = parseAsciiMath(cleanTerm);
355
+ this.activeNodeId = this.ast.id;
356
+ this.activeSubtreeIds = this.collectSubtreeIds(this.ast);
357
+ } catch (e: any) {
358
+ this.error = e.message || 'Fehler beim Parsen des Terms';
359
+ this.ast = undefined;
360
+ this.flatNodes = [];
361
+ this.edges = [];
78
362
  }
79
- .node[depth="5"] {
80
- top: calc(var(--kmap-term-tree-vertical-distance, 1.5em) * 7.5);
363
+ }
364
+
365
+ protected async updated(changedProperties: PropertyValues) {
366
+ if (changedProperties.has('term') || changedProperties.has('ast')) {
367
+ this.renderKaTeX();
368
+ await this.updateComplete;
369
+ this.recalculateLayout();
370
+ if (this.treeCanvasElement && this.resizeObserver) {
371
+ this.resizeObserver.observe(this.treeCanvasElement);
372
+ }
373
+ this.scheduleAnimationCheck();
81
374
  }
82
- .node[depth="6"] {
83
- top: calc(var(--kmap-term-tree-vertical-distance, 1.5em) * 9);
375
+ }
376
+
377
+ private renderKaTeX() {
378
+ if (!this.formulaElement || !this.ast) return;
379
+
380
+ try {
381
+ const latex = astToAnnotatedLatex(this.ast);
382
+ katex.render(latex, this.formulaElement, {
383
+ trust: true,
384
+ strict: false,
385
+ throwOnError: false,
386
+ });
387
+ } catch (err: any) {
388
+ this.error = err.message;
84
389
  }
85
- `;
390
+ }
86
391
 
87
- private parser: Parser = new Parser();
392
+ private recalculateLayout() {
393
+ if (
394
+ !this.ast ||
395
+ !this.formulaElement ||
396
+ !this.treeCanvasElement ||
397
+ !this.shadowRoot
398
+ )
399
+ return;
88
400
 
89
- @property({ type: String }) term?: string;
90
- @property({type: Number}) tension: number = 0;
401
+ const canvasRect = this.treeCanvasElement.getBoundingClientRect();
402
+ const offsetWidth = this.treeCanvasElement.offsetWidth;
403
+ const offsetHeight = this.treeCanvasElement.offsetHeight;
91
404
 
92
- @state() tokens?: Token[];
93
- @state() nodes?: TermNode[];
94
- @state() termNode?: TermNode;
95
- @state() depths?: {[key: string]: number};
96
- @state() maxDepths?: number;
405
+ if (canvasRect.width === 0 || offsetWidth === 0) return;
406
+ this.lastObservedWidth = offsetWidth;
97
407
 
98
- @state() connections?: Connection[];
408
+ const scaleX = canvasRect.width / offsetWidth;
409
+ const scaleY = offsetHeight > 0 ? canvasRect.height / offsetHeight : scaleX;
410
+ if (scaleX < 0.01 || scaleY < 0.01) return;
99
411
 
100
- @query("kmap-term-tree-edges") edgesElement?: KmapTermTreeEdge;
101
- @query("#tokens") tokensElement?: HTMLDivElement;
412
+ const flatNodes: TermNode[] = [];
413
+ const nodeMap = new Map<string, TermNode>();
102
414
 
103
- // @ts-ignore
104
- updateSlotted({target}) {
105
- // @ts-ignore
106
- this.term = target.assignedNodes().map((n) => n.textContent).join('');
107
- }
415
+ // 1. Collect all nodes and measure leaf positions from KaTeX DOM elements
416
+ const collectAndPositionLeaves = (node: TermNode) => {
417
+ nodeMap.set(node.id, node);
418
+
419
+ if (node.children.length === 0) {
420
+ // Leaf node: measure rendered KaTeX span
421
+ const el = this.shadowRoot!.getElementById(node.id);
422
+ if (el) {
423
+ const rect = el.getBoundingClientRect();
424
+ node.x = (rect.left - canvasRect.left + rect.width / 2) / scaleX;
425
+ node.y = (rect.bottom - canvasRect.top) / scaleY;
426
+ node.width = rect.width / scaleX;
427
+ node.height = rect.height / scaleY;
428
+ } else {
429
+ node.x = offsetWidth / 2;
430
+ node.y = 20;
431
+ }
432
+ } else {
433
+ flatNodes.push(node);
434
+ for (const child of node.children) {
435
+ collectAndPositionLeaves(child);
436
+ }
437
+ }
438
+ };
439
+
440
+ collectAndPositionLeaves(this.ast);
441
+
442
+ // 2. Position inner nodes bottom-up based on children coordinates
443
+ const formulaRect = this.formulaElement.getBoundingClientRect();
444
+ const formulaBottomRel = (formulaRect.bottom - canvasRect.top) / scaleY;
445
+ const initialGap = 34; // distance from formula bottom to level 1 operators
446
+ const rowHeight = 40; // distance between tree levels
108
447
 
109
- protected update(_changedProperties: PropertyValues) {
110
- if (_changedProperties.has("term")) {
111
- if (this.term !== undefined) {
112
- this.tokens = Token.tokenize(this.term);
113
- this.termNode = this.parser.parse(this.tokens);
114
-
115
- let map: {[key: string]: number} = {};
116
- this.maxDepths = this.calcDepths(this.termNode, map);
117
-
118
- let connections: Connection[] = [];
119
- this.termNode.breadthFirst((n) => {
120
- if (n.leftChildNode !== undefined)
121
- connections.push({from: n.token.id, to: n.leftChildNode.token.id})
122
- if (n.rightChildNode !== undefined)
123
- connections.push({from: n.token.id, to: n.rightChildNode.token.id})
448
+ const positionInnerNodes = (node: TermNode) => {
449
+ for (const child of node.children) {
450
+ if (child.children.length > 0) {
451
+ positionInnerNodes(child);
452
+ }
453
+ }
454
+
455
+ if (node.children.length > 0) {
456
+ if (node.type === 'function' || node.isUnary) {
457
+ // Center under the full function / unary expression: e.g. sin(arg) or -arg
458
+ const el = this.shadowRoot!.getElementById(node.id);
459
+ if (el) {
460
+ const rect = el.getBoundingClientRect();
461
+ node.x = (rect.left - canvasRect.left + rect.width / 2) / scaleX;
462
+ } else {
463
+ node.x = node.children[0].x ?? 0;
464
+ }
465
+ } else {
466
+ const first = node.children[0];
467
+ const last = node.children[node.children.length - 1];
468
+ node.x = ((first.x ?? 0) + (last.x ?? 0)) / 2;
469
+ }
470
+ node.y = formulaBottomRel + initialGap + (node.depth - 1) * rowHeight;
471
+ }
472
+ };
473
+
474
+ positionInnerNodes(this.ast);
475
+
476
+ // 3. Build edges between parent nodes and their children
477
+ const edges: TreeEdge[] = [];
478
+ for (const node of flatNodes) {
479
+ if (node.x === undefined || node.y === undefined) continue;
480
+
481
+ for (const child of node.children) {
482
+ if (child.x === undefined || child.y === undefined) continue;
483
+
484
+ edges.push({
485
+ fromNodeId: child.id,
486
+ toNodeId: node.id,
487
+ fromX: child.x,
488
+ fromY: child.y + (child.depth > 0 ? 14 : 2),
489
+ toX: node.x,
490
+ toY: node.y - 14,
124
491
  });
125
- this.depths = map;
126
- this.connections = connections;
127
492
  }
128
493
  }
129
- super.update(_changedProperties);
494
+
495
+ const maxDepth = this.ast.depth;
496
+ this.treeHeight = Math.max(
497
+ 60,
498
+ formulaBottomRel + initialGap + (maxDepth - 1) * rowHeight + 24
499
+ );
500
+ this.flatNodes = flatNodes;
501
+ this.edges = edges;
502
+ this.updateHighlightRect(this.activeNodeId);
503
+ this.requestUpdate();
130
504
  }
131
505
 
132
- private calcDepths(node: TermNode, map: { [p: string]: number }): number {
133
- let depth = 0;
134
- if (node.leftChildNode)
135
- depth = Math.max(depth, this.calcDepths(node.leftChildNode, map) + 1);
136
- if (node.rightChildNode)
137
- depth = Math.max(depth, this.calcDepths(node.rightChildNode, map) + 1);
138
- map[node.token.id] = depth;
139
- return depth;
506
+ private collectSubtreeIds(node: TermNode): Set<string> {
507
+ const ids = new Set<string>();
508
+ const traverse = (n: TermNode) => {
509
+ ids.add(n.id);
510
+ for (const c of n.children) traverse(c);
511
+ };
512
+ traverse(node);
513
+ return ids;
140
514
  }
141
515
 
142
- render() {
143
- return html`
144
- ${this.connections ? html`
145
- <kmap-term-tree-edges .tension="${this.tension}"></kmap-term-tree-edges>
146
- ` : ''}
147
- ${!this.tokens ? '' : html`
148
- <div class="tokens" id="tokens">
149
- ${this.tokens.map((t) => html`
150
- <div class="token" id="${t.id}">${KmapTermTree._prettify(t.value)}</div>
151
- `)}
152
- </div>
153
- <div class="notokens">
154
- ${this.tokens.map((t) => html`
155
- <div>${KmapTermTree._prettify(t.value)}</div>
156
- `)}
157
- </div>
158
- ` }
159
- ${this.tokens && this.depths ? html`
160
- <div class="nodes">
161
- ${this.tokens.map((t) => html`
162
- <div class="node" id="n${t.id}" depth="${ifDefined(this.depths ? this.depths[t.id]: undefined)}">${KmapTermTree._prettify(t.value)}</div>
163
- `)}
164
- </div>
165
- ` : ''}
166
- <div hidden>
167
- <slot @slotchange=${this.updateSlotted}></slot>
168
- </div>
169
- `;
516
+ private onNodeHover(node: TermNode, enter: boolean) {
517
+ if (!enter) {
518
+ // Revert highlight to root node
519
+ if (this.ast) {
520
+ this.setActiveNode(this.ast);
521
+ }
522
+ return;
523
+ }
524
+ this.setActiveNode(node);
170
525
  }
171
526
 
172
- protected async updated(_changedProperties: PropertyValues) {
173
- if (_changedProperties.has("connections")) {
174
- await this.updateComplete;
527
+ private setActiveNode(node: TermNode) {
528
+ this.activeNodeId = node.id;
529
+ this.activeSubtreeIds = this.collectSubtreeIds(node);
530
+ this.updateHighlightRect(node.id);
531
+ this.requestUpdate();
532
+ }
175
533
 
176
- if (this.tokensElement && this.maxDepths ) {
177
- let tokensHeight = this.tokensElement.offsetHeight;
178
- //console.log(this.maxDepths)
179
- //console.log(tokensHeight + "px")
180
- this.style.setProperty("--kmap-term-tree-max-depth", "" + this.maxDepths);
181
- this.style.setProperty("--kmap-term-tree-vertical-distance", tokensHeight + "px");
182
- this.style.height = "calc(3px + var(--kmap-term-tree-vertical-distance) * (var(--kmap-term-tree-max-depth)*1.5 + 1))";
183
- //console.log(this.style.height);
184
- }
534
+ private findNode(
535
+ root: TermNode | undefined,
536
+ id: string
537
+ ): TermNode | undefined {
538
+ if (!root) return undefined;
539
+ if (root.id === id) return root;
540
+ for (const child of root.children) {
541
+ const found = this.findNode(child, id);
542
+ if (found) return found;
543
+ }
544
+ return undefined;
545
+ }
185
546
 
186
- let edges: Edge[] = [];
187
- if (this.edgesElement && this.connections) {
188
- for (const connection of this.connections) {
189
- let edge = this._connect(connection);
190
- if (edge)
191
- edges.push(edge);
547
+ private getNodeVisualBounds(
548
+ node: TermNode
549
+ ): { top: number; bottom: number; left: number; right: number } | null {
550
+ if (!this.shadowRoot) return null;
551
+
552
+ let minTop = Infinity;
553
+ let maxBottom = -Infinity;
554
+ let minLeft = Infinity;
555
+ let maxRight = -Infinity;
556
+
557
+ const collectBounds = (n: TermNode) => {
558
+ const el = this.shadowRoot!.getElementById(n.id);
559
+ if (el) {
560
+ const r = el.getBoundingClientRect();
561
+ if (r.width > 0 && r.height > 0) {
562
+ minTop = Math.min(minTop, r.top);
563
+ maxBottom = Math.max(maxBottom, r.bottom);
564
+ minLeft = Math.min(minLeft, r.left);
565
+ maxRight = Math.max(maxRight, r.right);
192
566
  }
193
- this.edgesElement.edges = edges;
194
567
  }
195
- }
568
+ for (const child of n.children) {
569
+ collectBounds(child);
570
+ }
571
+ };
572
+
573
+ collectBounds(node);
574
+
575
+ if (minTop === Infinity) return null;
576
+ return { top: minTop, bottom: maxBottom, left: minLeft, right: maxRight };
196
577
  }
197
578
 
198
- _connect(connection: Connection): Edge | undefined {
199
- let from = this.shadowRoot.getElementById("n" + connection.from);
200
- let to = this.shadowRoot.getElementById("n" + connection.to);
201
- if (!from || !to)
579
+ private updateHighlightRect(nodeId?: string) {
580
+ if (!nodeId || !this.shadowRoot || !this.treeCanvasElement || !this.ast) {
581
+ this.highlightRect = undefined;
202
582
  return;
203
- let frompos = this._findAbsolutePosition(from);
204
- let topos = this._findAbsolutePosition(to);
583
+ }
205
584
 
206
- return { from: frompos, to: topos };
207
- }
585
+ const node = this.findNode(this.ast, nodeId);
586
+ if (!node) {
587
+ this.highlightRect = undefined;
588
+ return;
589
+ }
208
590
 
209
- _findAbsolutePosition(element: HTMLElement): Position {
210
- var x: number = 0;
211
- var y: number = 0;
212
- var el: HTMLElement | null = element;
213
- for (; el !== this; el = el.offsetParent as HTMLElement) {
214
- x += el.offsetLeft;
215
- y += el.offsetTop;
591
+ const bounds = this.getNodeVisualBounds(node);
592
+ if (!bounds) {
593
+ this.highlightRect = undefined;
594
+ return;
216
595
  }
217
- return {
218
- x: x,
219
- y: y,
220
- width: element.offsetWidth,
221
- height: element.offsetHeight,
222
- };
223
- }
224
596
 
225
- private static _prettify(value: string) {
226
- switch (value) {
227
- case '*':
228
- return "·"
229
- case '-':
230
- return "−"; // math minus
231
- default:
232
- return value;
597
+ const canvasRect = this.treeCanvasElement.getBoundingClientRect();
598
+ const offsetWidth = this.treeCanvasElement.offsetWidth;
599
+ const offsetHeight = this.treeCanvasElement.offsetHeight;
600
+ if (canvasRect.width === 0 || offsetWidth === 0) {
601
+ this.highlightRect = undefined;
602
+ return;
233
603
  }
234
- }
235
- }
236
604
 
237
- export class KmapTermTreeEdge extends LitElement {
238
- static styles = css`
239
- :host {
240
- display: block;
241
- stroke: var(--kmap-term-tree-edge-color, #fbc02d);
242
- position: relative;
605
+ const scaleX = canvasRect.width / offsetWidth;
606
+ const scaleY = offsetHeight > 0 ? canvasRect.height / offsetHeight : scaleX;
607
+ if (scaleX < 0.01 || scaleY < 0.01) {
608
+ this.highlightRect = undefined;
609
+ return;
243
610
  }
244
- `;
245
611
 
246
- @property({type: Number}) tension: number = .5;
247
- @property({type: Array}) edges: Edge[] = [];
612
+ const paddingX = 4;
613
+ const paddingY = 4;
248
614
 
249
- render() {
250
- const parent = this.offsetParent as HTMLElement;
251
- //language=SVG
252
- return this.edges === undefined ? '' : svg`
253
- <svg style="position:absolute;left:0px;top:0px" width="${parent.clientWidth}" height="${parent.clientHeight}">
254
- ${this.edges.map(edge => svg`
255
- <path d="${this._path(edge)}" fill="none" stroke-width="2" stroke-opacity=".9"/>
256
- `)}
257
- </svg>
258
- `;
615
+ this.highlightRect = {
616
+ left: (bounds.left - canvasRect.left) / scaleX - paddingX,
617
+ top: (bounds.top - canvasRect.top) / scaleY - paddingY,
618
+ width: (bounds.right - bounds.left) / scaleX + paddingX * 2,
619
+ height: (bounds.bottom - bounds.top) / scaleY + paddingY * 2,
620
+ };
259
621
  }
260
622
 
261
- _path(edge: Edge) {
262
- let fromx = edge.from.x + edge.from.width / 2;
263
- let fromy = edge.from.y + edge.from.height / 2;
264
- let tox = edge.to.x + edge.to.width / 2;
265
- let toy = edge.to.y + edge.to.height / 2;
266
-
267
- var delta = (toy-fromy) * this.tension;
268
- var hx1 = fromx;
269
- var hy1 = fromy + delta;
270
- var hx2 = tox;
271
- var hy2 = toy - delta;
272
- return "M " + fromx + " " + fromy +
273
- " C " + hx1 + " " + hy1
274
- + " " + hx2 + " " + hy2
275
- + " " + tox + " " + toy;
623
+ private renderPath(edge: TreeEdge): string {
624
+ const delta = (edge.toY - edge.fromY) * this.tension;
625
+ const hx1 = edge.fromX;
626
+ const hy1 = edge.fromY + delta;
627
+ const hx2 = edge.toX;
628
+ const hy2 = edge.toY - delta;
629
+
630
+ return `M ${edge.fromX} ${edge.fromY} C ${hx1} ${hy1} ${hx2} ${hy2} ${edge.toX} ${edge.toY}`;
276
631
  }
277
632
 
633
+ render() {
634
+ const activeNode =
635
+ this.flatNodes.find(n => n.id === this.activeNodeId) || this.ast;
636
+ const isRoot = activeNode?.id === this.ast?.id;
637
+
638
+ return html`
639
+ <div class="container">
640
+ <!-- Canvas area with formula, edges, and operator nodes sharing the same coordinate space -->
641
+ <div class="tree-canvas" style="height: ${this.treeHeight}px">
642
+ ${this.highlightRect
643
+ ? html`
644
+ <div
645
+ class="highlight-overlay"
646
+ style="left: ${this.highlightRect.left}px; top: ${this
647
+ .highlightRect.top}px; width: ${this.highlightRect
648
+ .width}px; height: ${this.highlightRect.height}px;"
649
+ ></div>
650
+ `
651
+ : ''}
652
+
653
+ <div class="formula-wrapper">
654
+ <div id="formula"></div>
655
+ </div>
656
+
657
+ ${this.error
658
+ ? html`<div class="error-msg">${this.error}</div>`
659
+ : html`
660
+ <svg class="edges-svg">
661
+ ${this.edges.map(
662
+ edge => svg`
663
+ <path
664
+ class="edge-path ${
665
+ this.activeSubtreeIds.has(edge.fromNodeId) &&
666
+ this.activeSubtreeIds.has(edge.toNodeId)
667
+ ? 'highlighted'
668
+ : ''
669
+ }"
670
+ d="${this.renderPath(edge)}"
671
+ />
672
+ `
673
+ )}
674
+ </svg>
675
+
676
+ ${this.flatNodes.map(
677
+ node => html`
678
+ <div
679
+ class="tree-node ${this.activeNodeId === node.id
680
+ ? 'active'
681
+ : ''}"
682
+ style="left: ${node.x}px; top: ${node.y}px;"
683
+ @mouseenter=${() => this.onNodeHover(node, true)}
684
+ @mouseleave=${() => this.onNodeHover(node, false)}
685
+ @click=${() => this.setActiveNode(node)}
686
+ >
687
+ <div class="node-badge" title="${node.label}">
688
+ ${node.symbol}
689
+ </div>
690
+ ${this.showLabels
691
+ ? html`<span class="node-label">${node.label}</span>`
692
+ : ''}
693
+ </div>
694
+ `
695
+ )}
696
+ `}
697
+ </div>
698
+
699
+ <!-- Didactic info box -->
700
+ ${this.showDetails && activeNode
701
+ ? html`
702
+ <div class="info-bar">
703
+ <span>${isRoot ? 'Hauptrechenart:' : 'Teilterm:'}</span>
704
+ <span class="info-badge">${activeNode.label}</span>
705
+ <span style="color: #64748b">
706
+ ${activeNode.children.length === 2
707
+ ? `(verknüpft 2 Teilterme mit ${activeNode.symbol})`
708
+ : activeNode.isUnary
709
+ ? `(wirkt auf ${
710
+ activeNode.children[0].label || 'Ausdruck'
711
+ })`
712
+ : ''}
713
+ </span>
714
+ </div>
715
+ `
716
+ : ''}
717
+
718
+ <div hidden>
719
+ <slot @slotchange=${this.updateSlotted}></slot>
720
+ </div>
721
+ </div>
722
+ `;
723
+ }
278
724
  }
279
- window.customElements.define('kmap-term-tree-edges', KmapTermTreeEdge);