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.
- package/demo/index.html +124 -10
- package/demo/kmap.html +64 -0
- package/dist/src/KmapTermTree.d.ts +37 -46
- package/dist/src/KmapTermTree.js +641 -216
- package/dist/src/KmapTermTree.js.map +1 -1
- package/dist/src/asciimath-parser.d.ts +17 -0
- package/dist/src/asciimath-parser.js +472 -0
- package/dist/src/asciimath-parser.js.map +1 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/katex-css.d.ts +2 -0
- package/dist/src/katex-css.js +7 -0
- package/dist/src/katex-css.js.map +1 -0
- package/dist/src/model.d.ts +30 -0
- package/dist/src/model.js +26 -0
- package/dist/src/model.js.map +1 -0
- package/dist/src/parser.js.map +1 -1
- package/dist/src/tokenizer.js.map +1 -1
- package/dist/stories/index.stories.d.ts +20 -0
- package/dist/stories/index.stories.js +26 -2
- package/dist/stories/index.stories.js.map +1 -1
- package/dist/test/asciimath-parser.test.d.ts +1 -0
- package/dist/test/asciimath-parser.test.js +45 -0
- package/dist/test/asciimath-parser.test.js.map +1 -0
- package/dist/test/kmap-term-tree.test.js +45 -8
- package/dist/test/kmap-term-tree.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +16 -50
- package/src/KmapTermTree.ts +675 -230
- package/src/asciimath-parser.ts +554 -0
- package/src/index.ts +2 -0
- package/src/katex-css.ts +7 -0
- package/src/model.ts +57 -0
- package/stories/index.stories.ts +35 -2
- package/test/asciimath-parser.test.ts +50 -0
- package/test/kmap-term-tree.test.ts +66 -7
package/src/KmapTermTree.ts
CHANGED
|
@@ -1,279 +1,724 @@
|
|
|
1
|
-
import {css, html, LitElement, PropertyValues, svg} from 'lit';
|
|
2
|
-
import {property, state, query} from 'lit/decorators.js';
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
49
|
-
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
disconnectedCallback() {
|
|
240
|
+
if (this.resizeFrameId) {
|
|
241
|
+
cancelAnimationFrame(this.resizeFrameId);
|
|
50
242
|
}
|
|
51
|
-
.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
|
|
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
|
-
.
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
protected willUpdate(changedProperties: PropertyValues) {
|
|
338
|
+
if (changedProperties.has('term') && this.term !== undefined) {
|
|
339
|
+
this.parseInput(this.term);
|
|
75
340
|
}
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
392
|
+
private recalculateLayout() {
|
|
393
|
+
if (
|
|
394
|
+
!this.ast ||
|
|
395
|
+
!this.formulaElement ||
|
|
396
|
+
!this.treeCanvasElement ||
|
|
397
|
+
!this.shadowRoot
|
|
398
|
+
)
|
|
399
|
+
return;
|
|
88
400
|
|
|
89
|
-
|
|
90
|
-
|
|
401
|
+
const canvasRect = this.treeCanvasElement.getBoundingClientRect();
|
|
402
|
+
const offsetWidth = this.treeCanvasElement.offsetWidth;
|
|
403
|
+
const offsetHeight = this.treeCanvasElement.offsetHeight;
|
|
91
404
|
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
-
|
|
412
|
+
const flatNodes: TermNode[] = [];
|
|
413
|
+
const nodeMap = new Map<string, TermNode>();
|
|
102
414
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
return
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
204
|
-
let topos = this._findAbsolutePosition(to);
|
|
583
|
+
}
|
|
205
584
|
|
|
206
|
-
|
|
207
|
-
|
|
585
|
+
const node = this.findNode(this.ast, nodeId);
|
|
586
|
+
if (!node) {
|
|
587
|
+
this.highlightRect = undefined;
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
208
590
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
247
|
-
|
|
612
|
+
const paddingX = 4;
|
|
613
|
+
const paddingY = 4;
|
|
248
614
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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);
|