kmap-term-tree 0.9.21 → 0.9.22

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.
@@ -1,279 +1,601 @@
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%;
47
- }
48
- .token {
49
- display: inline-block;
50
- }
51
- .token, .node {
52
- display: inline-block;
53
- position: relative;
54
- background-color: var(--kmap-term-tree-background-color, white);
55
- }
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;
63
- }
64
- .node[depth="0"], .node:not([depth]) {
65
- visibility: hidden;
66
- }
67
- .node[depth="1"] {
68
- top: calc(var(--kmap-term-tree-vertical-distance, 1.5em) * 1.5);
69
- }
70
- .node[depth="2"] {
71
- top: calc(var(--kmap-term-tree-vertical-distance, 1.5em) * 3);
72
- }
73
- .node[depth="3"] {
74
- top: calc(var(--kmap-term-tree-vertical-distance, 1.5em) * 4.5);
75
- }
76
- .node[depth="4"] {
77
- top: calc(var(--kmap-term-tree-vertical-distance, 1.5em) * 6);
78
- }
79
- .node[depth="5"] {
80
- top: calc(var(--kmap-term-tree-vertical-distance, 1.5em) * 7.5);
81
- }
82
- .node[depth="6"] {
83
- top: calc(var(--kmap-term-tree-vertical-distance, 1.5em) * 9);
84
- }
85
- `;
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
+ }
86
25
 
87
- private parser: Parser = new Parser();
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
+ ];
88
187
 
89
188
  @property({ type: String }) term?: string;
90
- @property({type: Number}) tension: number = 0;
189
+ @property({ type: Number }) tension: number = 0.5;
190
+ @property({ type: Boolean }) showLabels: boolean = false;
191
+ @property({ type: Boolean }) showDetails: boolean = true;
91
192
 
92
- @state() tokens?: Token[];
93
- @state() nodes?: TermNode[];
94
- @state() termNode?: TermNode;
95
- @state() depths?: {[key: string]: number};
96
- @state() maxDepths?: number;
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
+ };
97
206
 
98
- @state() connections?: Connection[];
207
+ @query('#formula') private formulaElement?: HTMLElement;
208
+ @query('.tree-canvas') private treeCanvasElement?: HTMLElement;
99
209
 
100
- @query("kmap-term-tree-edges") edgesElement?: KmapTermTreeEdge;
101
- @query("#tokens") tokensElement?: HTMLDivElement;
210
+ private resizeObserver?: ResizeObserver;
102
211
 
103
- // @ts-ignore
104
- updateSlotted({target}) {
105
- // @ts-ignore
106
- this.term = target.assignedNodes().map((n) => n.textContent).join('');
212
+ connectedCallback() {
213
+ super.connectedCallback();
214
+ this.resizeObserver = new ResizeObserver(() => {
215
+ this.recalculateLayout();
216
+ });
107
217
  }
108
218
 
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})
124
- });
125
- this.depths = map;
126
- this.connections = connections;
127
- }
219
+ disconnectedCallback() {
220
+ this.resizeObserver?.disconnect();
221
+ super.disconnectedCallback();
222
+ }
223
+
224
+ // Support slotted content: <kmap-term-tree>2a+3b</kmap-term-tree>
225
+ updateSlotted(event: Event) {
226
+ const slot = event.target as HTMLSlotElement;
227
+ const text = slot
228
+ .assignedNodes()
229
+ .map(n => n.textContent)
230
+ .join('')
231
+ .trim();
232
+ if (text && text !== this.term) {
233
+ this.term = text;
128
234
  }
129
- super.update(_changedProperties);
130
235
  }
131
236
 
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;
237
+ protected willUpdate(changedProperties: PropertyValues) {
238
+ if (changedProperties.has('term') && this.term !== undefined) {
239
+ this.parseInput(this.term);
240
+ }
140
241
  }
141
242
 
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
- `;
243
+ private parseInput(termStr: string) {
244
+ try {
245
+ this.error = undefined;
246
+ const cleanTerm = termStr.trim();
247
+ if (!cleanTerm) {
248
+ this.ast = undefined;
249
+ this.flatNodes = [];
250
+ this.edges = [];
251
+ return;
252
+ }
253
+
254
+ this.ast = parseAsciiMath(cleanTerm);
255
+ this.activeNodeId = this.ast.id;
256
+ this.activeSubtreeIds = this.collectSubtreeIds(this.ast);
257
+ } catch (e: any) {
258
+ this.error = e.message || 'Fehler beim Parsen des Terms';
259
+ this.ast = undefined;
260
+ this.flatNodes = [];
261
+ this.edges = [];
262
+ }
170
263
  }
171
264
 
172
- protected async updated(_changedProperties: PropertyValues) {
173
- if (_changedProperties.has("connections")) {
265
+ protected async updated(changedProperties: PropertyValues) {
266
+ if (changedProperties.has('term') || changedProperties.has('ast')) {
267
+ this.renderKaTeX();
174
268
  await this.updateComplete;
269
+ this.recalculateLayout();
270
+ if (this.treeCanvasElement && this.resizeObserver) {
271
+ this.resizeObserver.observe(this.treeCanvasElement);
272
+ }
273
+ }
274
+ }
275
+
276
+ private renderKaTeX() {
277
+ if (!this.formulaElement || !this.ast) return;
278
+
279
+ try {
280
+ const latex = astToAnnotatedLatex(this.ast);
281
+ katex.render(latex, this.formulaElement, {
282
+ trust: true,
283
+ strict: false,
284
+ throwOnError: false,
285
+ });
286
+ } catch (err: any) {
287
+ this.error = err.message;
288
+ }
289
+ }
290
+
291
+ private recalculateLayout() {
292
+ if (
293
+ !this.ast ||
294
+ !this.formulaElement ||
295
+ !this.treeCanvasElement ||
296
+ !this.shadowRoot
297
+ )
298
+ return;
299
+
300
+ const canvasRect = this.treeCanvasElement.getBoundingClientRect();
301
+ if (canvasRect.width === 0) return;
302
+
303
+ const flatNodes: TermNode[] = [];
304
+ const nodeMap = new Map<string, TermNode>();
305
+
306
+ // 1. Collect all nodes and measure leaf positions from KaTeX DOM elements
307
+ const collectAndPositionLeaves = (node: TermNode) => {
308
+ nodeMap.set(node.id, node);
309
+
310
+ if (node.children.length === 0) {
311
+ // Leaf node: measure rendered KaTeX span
312
+ const el = this.shadowRoot!.getElementById(node.id);
313
+ if (el) {
314
+ const rect = el.getBoundingClientRect();
315
+ node.x = rect.left - canvasRect.left + rect.width / 2;
316
+ node.y = rect.bottom - canvasRect.top;
317
+ node.width = rect.width;
318
+ node.height = rect.height;
319
+ } else {
320
+ node.x = canvasRect.width / 2;
321
+ node.y = 20;
322
+ }
323
+ } else {
324
+ flatNodes.push(node);
325
+ for (const child of node.children) {
326
+ collectAndPositionLeaves(child);
327
+ }
328
+ }
329
+ };
175
330
 
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);
331
+ collectAndPositionLeaves(this.ast);
332
+
333
+ // 2. Position inner nodes bottom-up based on children coordinates
334
+ const formulaRect = this.formulaElement.getBoundingClientRect();
335
+ const formulaBottomRel = formulaRect.bottom - canvasRect.top;
336
+ const initialGap = 34; // distance from formula bottom to level 1 operators
337
+ const rowHeight = 40; // distance between tree levels
338
+
339
+ const positionInnerNodes = (node: TermNode) => {
340
+ for (const child of node.children) {
341
+ if (child.children.length > 0) {
342
+ positionInnerNodes(child);
343
+ }
184
344
  }
185
345
 
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);
346
+ if (node.children.length > 0) {
347
+ if (node.type === 'function' || node.isUnary) {
348
+ // Center under the full function / unary expression: e.g. sin(arg) or -arg
349
+ const el = this.shadowRoot!.getElementById(node.id);
350
+ if (el) {
351
+ const rect = el.getBoundingClientRect();
352
+ node.x = rect.left - canvasRect.left + rect.width / 2;
353
+ } else {
354
+ node.x = node.children[0].x ?? 0;
355
+ }
356
+ } else {
357
+ const first = node.children[0];
358
+ const last = node.children[node.children.length - 1];
359
+ node.x = ((first.x ?? 0) + (last.x ?? 0)) / 2;
192
360
  }
193
- this.edgesElement.edges = edges;
361
+ node.y = formulaBottomRel + initialGap + (node.depth - 1) * rowHeight;
362
+ }
363
+ };
364
+
365
+ positionInnerNodes(this.ast);
366
+
367
+ // 3. Build edges between parent nodes and their children
368
+ const edges: TreeEdge[] = [];
369
+ for (const node of flatNodes) {
370
+ if (node.x === undefined || node.y === undefined) continue;
371
+
372
+ for (const child of node.children) {
373
+ if (child.x === undefined || child.y === undefined) continue;
374
+
375
+ edges.push({
376
+ fromNodeId: child.id,
377
+ toNodeId: node.id,
378
+ fromX: child.x,
379
+ fromY: child.y + (child.depth > 0 ? 14 : 2),
380
+ toX: node.x,
381
+ toY: node.y - 14,
382
+ });
194
383
  }
195
384
  }
385
+
386
+ const maxDepth = this.ast.depth;
387
+ this.treeHeight = Math.max(
388
+ 60,
389
+ formulaBottomRel + initialGap + (maxDepth - 1) * rowHeight + 24
390
+ );
391
+ this.flatNodes = flatNodes;
392
+ this.edges = edges;
393
+ this.updateHighlightRect(this.activeNodeId);
394
+ this.requestUpdate();
395
+ }
396
+
397
+ private collectSubtreeIds(node: TermNode): Set<string> {
398
+ const ids = new Set<string>();
399
+ const traverse = (n: TermNode) => {
400
+ ids.add(n.id);
401
+ for (const c of n.children) traverse(c);
402
+ };
403
+ traverse(node);
404
+ return ids;
196
405
  }
197
406
 
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)
407
+ private onNodeHover(node: TermNode, enter: boolean) {
408
+ if (!enter) {
409
+ // Revert highlight to root node
410
+ if (this.ast) {
411
+ this.setActiveNode(this.ast);
412
+ }
202
413
  return;
203
- let frompos = this._findAbsolutePosition(from);
204
- let topos = this._findAbsolutePosition(to);
414
+ }
415
+ this.setActiveNode(node);
416
+ }
205
417
 
206
- return { from: frompos, to: topos };
418
+ private setActiveNode(node: TermNode) {
419
+ this.activeNodeId = node.id;
420
+ this.activeSubtreeIds = this.collectSubtreeIds(node);
421
+ this.updateHighlightRect(node.id);
422
+ this.requestUpdate();
207
423
  }
208
424
 
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;
425
+ private findNode(
426
+ root: TermNode | undefined,
427
+ id: string
428
+ ): TermNode | undefined {
429
+ if (!root) return undefined;
430
+ if (root.id === id) return root;
431
+ for (const child of root.children) {
432
+ const found = this.findNode(child, id);
433
+ if (found) return found;
216
434
  }
217
- return {
218
- x: x,
219
- y: y,
220
- width: element.offsetWidth,
221
- height: element.offsetHeight,
435
+ return undefined;
436
+ }
437
+
438
+ private getNodeVisualBounds(
439
+ node: TermNode
440
+ ): { top: number; bottom: number; left: number; right: number } | null {
441
+ if (!this.shadowRoot) return null;
442
+
443
+ let minTop = Infinity;
444
+ let maxBottom = -Infinity;
445
+ let minLeft = Infinity;
446
+ let maxRight = -Infinity;
447
+
448
+ const collectBounds = (n: TermNode) => {
449
+ const el = this.shadowRoot!.getElementById(n.id);
450
+ if (el) {
451
+ const r = el.getBoundingClientRect();
452
+ if (r.width > 0 && r.height > 0) {
453
+ minTop = Math.min(minTop, r.top);
454
+ maxBottom = Math.max(maxBottom, r.bottom);
455
+ minLeft = Math.min(minLeft, r.left);
456
+ maxRight = Math.max(maxRight, r.right);
457
+ }
458
+ }
459
+ for (const child of n.children) {
460
+ collectBounds(child);
461
+ }
222
462
  };
463
+
464
+ collectBounds(node);
465
+
466
+ if (minTop === Infinity) return null;
467
+ return { top: minTop, bottom: maxBottom, left: minLeft, right: maxRight };
223
468
  }
224
469
 
225
- private static _prettify(value: string) {
226
- switch (value) {
227
- case '*':
228
- return "·"
229
- case '-':
230
- return "−"; // math minus
231
- default:
232
- return value;
470
+ private updateHighlightRect(nodeId?: string) {
471
+ if (!nodeId || !this.shadowRoot || !this.treeCanvasElement || !this.ast) {
472
+ this.highlightRect = undefined;
473
+ return;
233
474
  }
234
- }
235
- }
236
475
 
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;
476
+ const node = this.findNode(this.ast, nodeId);
477
+ if (!node) {
478
+ this.highlightRect = undefined;
479
+ return;
243
480
  }
244
- `;
245
481
 
246
- @property({type: Number}) tension: number = .5;
247
- @property({type: Array}) edges: Edge[] = [];
482
+ const bounds = this.getNodeVisualBounds(node);
483
+ if (!bounds) {
484
+ this.highlightRect = undefined;
485
+ return;
486
+ }
248
487
 
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
- `;
488
+ const canvasRect = this.treeCanvasElement.getBoundingClientRect();
489
+ const paddingX = 4;
490
+ const paddingY = 4;
491
+
492
+ this.highlightRect = {
493
+ left: bounds.left - canvasRect.left - paddingX,
494
+ top: bounds.top - canvasRect.top - paddingY,
495
+ width: bounds.right - bounds.left + paddingX * 2,
496
+ height: bounds.bottom - bounds.top + paddingY * 2,
497
+ };
259
498
  }
260
499
 
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;
500
+ private renderPath(edge: TreeEdge): string {
501
+ const delta = (edge.toY - edge.fromY) * this.tension;
502
+ const hx1 = edge.fromX;
503
+ const hy1 = edge.fromY + delta;
504
+ const hx2 = edge.toX;
505
+ const hy2 = edge.toY - delta;
506
+
507
+ return `M ${edge.fromX} ${edge.fromY} C ${hx1} ${hy1} ${hx2} ${hy2} ${edge.toX} ${edge.toY}`;
276
508
  }
277
509
 
510
+ render() {
511
+ const activeNode =
512
+ this.flatNodes.find(n => n.id === this.activeNodeId) || this.ast;
513
+ const isRoot = activeNode?.id === this.ast?.id;
514
+
515
+ return html`
516
+ <div class="container">
517
+ <!-- Canvas area with formula, edges, and operator nodes sharing the same coordinate space -->
518
+ <div class="tree-canvas" style="height: ${this.treeHeight}px">
519
+ ${this.highlightRect
520
+ ? html`
521
+ <div
522
+ class="highlight-overlay"
523
+ style="left: ${this.highlightRect.left}px; top: ${this
524
+ .highlightRect.top}px; width: ${this.highlightRect
525
+ .width}px; height: ${this.highlightRect.height}px;"
526
+ ></div>
527
+ `
528
+ : ''}
529
+
530
+ <div class="formula-wrapper">
531
+ <div id="formula"></div>
532
+ </div>
533
+
534
+ ${this.error
535
+ ? html`<div class="error-msg">${this.error}</div>`
536
+ : html`
537
+ <svg class="edges-svg">
538
+ ${this.edges.map(
539
+ edge => svg`
540
+ <path
541
+ class="edge-path ${
542
+ this.activeSubtreeIds.has(edge.fromNodeId) &&
543
+ this.activeSubtreeIds.has(edge.toNodeId)
544
+ ? 'highlighted'
545
+ : ''
546
+ }"
547
+ d="${this.renderPath(edge)}"
548
+ />
549
+ `
550
+ )}
551
+ </svg>
552
+
553
+ ${this.flatNodes.map(
554
+ node => html`
555
+ <div
556
+ class="tree-node ${this.activeNodeId === node.id
557
+ ? 'active'
558
+ : ''}"
559
+ style="left: ${node.x}px; top: ${node.y}px;"
560
+ @mouseenter=${() => this.onNodeHover(node, true)}
561
+ @mouseleave=${() => this.onNodeHover(node, false)}
562
+ @click=${() => this.setActiveNode(node)}
563
+ >
564
+ <div class="node-badge" title="${node.label}">
565
+ ${node.symbol}
566
+ </div>
567
+ ${this.showLabels
568
+ ? html`<span class="node-label">${node.label}</span>`
569
+ : ''}
570
+ </div>
571
+ `
572
+ )}
573
+ `}
574
+ </div>
575
+
576
+ <!-- Didactic info box -->
577
+ ${this.showDetails && activeNode
578
+ ? html`
579
+ <div class="info-bar">
580
+ <span>${isRoot ? 'Hauptrechenart:' : 'Teilterm:'}</span>
581
+ <span class="info-badge">${activeNode.label}</span>
582
+ <span style="color: #64748b">
583
+ ${activeNode.children.length === 2
584
+ ? `(verknüpft 2 Teilterme mit ${activeNode.symbol})`
585
+ : activeNode.isUnary
586
+ ? `(wirkt auf ${
587
+ activeNode.children[0].label || 'Ausdruck'
588
+ })`
589
+ : ''}
590
+ </span>
591
+ </div>
592
+ `
593
+ : ''}
594
+
595
+ <div hidden>
596
+ <slot @slotchange=${this.updateSlotted}></slot>
597
+ </div>
598
+ </div>
599
+ `;
600
+ }
278
601
  }
279
- window.customElements.define('kmap-term-tree-edges', KmapTermTreeEdge);