@teachinglab/omd 0.7.30 → 0.7.32

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.
@@ -20,6 +20,7 @@ export class ResizeHandleManager {
20
20
  this.selectionBorderColor = '#007bff';
21
21
  this.selectionBorderWidth = 2;
22
22
  this.selectionBorder = null;
23
+ this.selectionBorderPadding = { top: 8, right: 8, bottom: 8, left: 8 };
23
24
 
24
25
  // Resize constraints
25
26
  this.minSize = 20;
@@ -270,11 +271,19 @@ export class ResizeHandleManager {
270
271
  * @param {string} [style.dasharray] - SVG stroke-dasharray value (e.g. '4,2' or 'none')
271
272
  * @param {number} [style.cornerRadius]- rx/ry corner radius of the border rect
272
273
  */
273
- setSelectionStyle({ color, width, dasharray, cornerRadius } = {}) {
274
+ setSelectionStyle({ color, width, dasharray, cornerRadius, padding, paddingX, paddingY, paddingTop, paddingRight, paddingBottom, paddingLeft } = {}) {
274
275
  if (color !== undefined) this.selectionBorderColor = color;
275
276
  if (width !== undefined) this.selectionBorderWidth = width;
276
277
  if (dasharray !== undefined) this.selectionBorderDasharray = dasharray;
277
278
  if (cornerRadius !== undefined) this.selectionBorderCornerRadius = cornerRadius;
279
+ const basePadding = padding ?? null;
280
+ const nextPadding = {
281
+ top: paddingTop ?? paddingY ?? basePadding ?? this.selectionBorderPadding.top,
282
+ right: paddingRight ?? paddingX ?? basePadding ?? this.selectionBorderPadding.right,
283
+ bottom: paddingBottom ?? paddingY ?? basePadding ?? this.selectionBorderPadding.bottom,
284
+ left: paddingLeft ?? paddingX ?? basePadding ?? this.selectionBorderPadding.left
285
+ };
286
+ this.selectionBorderPadding = nextPadding;
278
287
 
279
288
  // Re-apply to live border if one exists
280
289
  if (this.selectionBorder) {
@@ -286,6 +295,7 @@ export class ResizeHandleManager {
286
295
  this.selectionBorder.setAttribute('rx', this.selectionBorderCornerRadius);
287
296
  this.selectionBorder.setAttribute('ry', this.selectionBorderCornerRadius);
288
297
  }
298
+ this._updateSelectionBorder();
289
299
  }
290
300
  }
291
301
 
@@ -385,12 +395,12 @@ export class ResizeHandleManager {
385
395
  if (!this.selectionBorder || !this.selectedElement) return;
386
396
 
387
397
  const bounds = this._getTransformedBounds();
388
- const padding = 3;
398
+ const padding = this.selectionBorderPadding;
389
399
 
390
- this.selectionBorder.setAttribute('x', bounds.x - padding);
391
- this.selectionBorder.setAttribute('y', bounds.y - padding);
392
- this.selectionBorder.setAttribute('width', bounds.width + padding * 2);
393
- this.selectionBorder.setAttribute('height', bounds.height + padding * 2);
400
+ this.selectionBorder.setAttribute('x', bounds.x - padding.left);
401
+ this.selectionBorder.setAttribute('y', bounds.y - padding.top);
402
+ this.selectionBorder.setAttribute('width', bounds.width + padding.left + padding.right);
403
+ this.selectionBorder.setAttribute('height', bounds.height + padding.top + padding.bottom);
394
404
  }
395
405
 
396
406
  /**
@@ -50,6 +50,13 @@ export class PointerTool extends Tool {
50
50
  this.dragStartPoint = null;
51
51
  this.potentialDeselect = null;
52
52
  this.hasSeparatedForDrag = false;
53
+
54
+ this.selectionBoundsPadding = {
55
+ top: options.selectionBoundsPaddingTop ?? options.selectionBoundsPaddingY ?? options.selectionBoundsPadding ?? 14,
56
+ right: options.selectionBoundsPaddingRight ?? options.selectionBoundsPaddingX ?? options.selectionBoundsPadding ?? 14,
57
+ bottom: options.selectionBoundsPaddingBottom ?? options.selectionBoundsPaddingY ?? options.selectionBoundsPadding ?? 14,
58
+ left: options.selectionBoundsPaddingLeft ?? options.selectionBoundsPaddingX ?? options.selectionBoundsPadding ?? 14
59
+ };
53
60
 
54
61
  // Initialize resize handle manager for OMD visuals
55
62
  this.resizeHandleManager = new ResizeHandleManager(canvas);
@@ -595,12 +602,12 @@ export class PointerTool extends Tool {
595
602
  if (!hasPoints) return null;
596
603
 
597
604
  // Add padding to match the visual box
598
- const padding = 8;
605
+ const padding = this.selectionBoundsPadding;
599
606
  return {
600
- x: minX - padding,
601
- y: minY - padding,
602
- width: (maxX + padding) - (minX - padding),
603
- height: (maxY + padding) - (minY - padding)
607
+ x: minX - padding.left,
608
+ y: minY - padding.top,
609
+ width: (maxX + padding.right) - (minX - padding.left),
610
+ height: (maxY + padding.bottom) - (minY - padding.top)
604
611
  };
605
612
  }
606
613
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teachinglab/omd",
3
- "version": "0.7.30",
3
+ "version": "0.7.32",
4
4
  "description": "omd",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",
@@ -785,20 +785,29 @@ This document provides schemas and examples for the `loadFromJSON` method used i
785
785
  "backgroundColor": "string",
786
786
  "backgroundCornerRadius": "number",
787
787
  "backgroundOpacity": "number",
788
- "showBackground": "boolean"
788
+ "showBackground": "boolean",
789
+ "interactive": "boolean (optional) - when true, render sliders below the graph",
790
+ "variables": [
791
+ "array of strings or objects: { name, label, value, min, max, step, color, graphIndex }"
792
+ ]
789
793
  }
790
794
  ```
791
795
 
792
796
  ### Example
793
797
  ```json
794
798
  {
799
+ "interactive": true,
800
+ "variables": [
801
+ { "name": "a", "label": "a", "value": 2, "min": -5, "max": 5, "step": 0.1 },
802
+ { "name": "b", "label": "b", "value": 1, "min": -5, "max": 5, "step": 0.1 }
803
+ ],
795
804
  "graphEquations": [
796
805
  {
797
- "equation": "y = x^2",
798
- "color": "blue",
806
+ "equation": "y = a*x + b",
807
+ "color": "#0f766e",
799
808
  "strokeWidth": 2,
800
809
  "domain": { "min": -5, "max": 5 },
801
- "label": "f(x) = x²",
810
+ "label": "y = a*x + b",
802
811
  "labelAtX": 2,
803
812
  "labelPosition": "above"
804
813
  }
@@ -51,6 +51,13 @@ export class omdCoordinatePlane extends jsvgGroup {
51
51
  this.backgroundOpacity = 1.0;
52
52
  this.showBackground = true;
53
53
 
54
+ this.interactive = false;
55
+ this.variables = [];
56
+ this.basePaddingBottom = 30;
57
+ this.interactivePaddingBottom = 0;
58
+ this.renderedFunctionEntries = [];
59
+ this.sliderControlRows = [];
60
+
54
61
  this.calculatePadding();
55
62
  this.updateLayout();
56
63
  }
@@ -85,6 +92,10 @@ export class omdCoordinatePlane extends jsvgGroup {
85
92
  this.backgroundCornerRadius = (data.backgroundCornerRadius !== undefined) ? data.backgroundCornerRadius : this.backgroundCornerRadius;
86
93
  this.backgroundOpacity = (data.backgroundOpacity !== undefined) ? data.backgroundOpacity : this.backgroundOpacity;
87
94
  this.showBackground = (data.showBackground !== undefined) ? data.showBackground : this.showBackground;
95
+ this.interactive = data.interactive === true;
96
+ this.variables = this.normalizeInteractiveVariables(
97
+ data.variables || data.interactiveVariables || []
98
+ );
88
99
 
89
100
  this.calculatePadding();
90
101
  this.updateLayout();
@@ -153,6 +164,11 @@ export class omdCoordinatePlane extends jsvgGroup {
153
164
  const shapesHolder = new jsvgGroup();
154
165
  graphClipMask.addChild(shapesHolder);
155
166
  this.addShapes(shapesHolder);
167
+
168
+ this.sliderControlRows = [];
169
+ if (this.shouldShowInteractiveControls()) {
170
+ this.createInteractiveControls(contentGroup.svgObject);
171
+ }
156
172
  }
157
173
 
158
174
  createAxisGrid(gridHolder, isXAxis) {
@@ -280,7 +296,11 @@ export class omdCoordinatePlane extends jsvgGroup {
280
296
 
281
297
  calculatePadding() {
282
298
  this.paddingLeft = this.yLabel ? 50 : 30;
283
- this.paddingBottom = this.xLabel ? 50 : 30;
299
+ this.basePaddingBottom = this.xLabel ? 50 : 30;
300
+ this.interactivePaddingBottom = this.shouldShowInteractiveControls()
301
+ ? this.getInteractiveControlHeight()
302
+ : 0;
303
+ this.paddingBottom = this.basePaddingBottom + this.interactivePaddingBottom;
284
304
  this.paddingTop = 25;
285
305
  this.paddingRight = 25;
286
306
  }
@@ -374,17 +394,29 @@ export class omdCoordinatePlane extends jsvgGroup {
374
394
  }
375
395
 
376
396
  graphMultipleFunctions(holder) {
397
+ this.renderedFunctionEntries = [];
398
+
377
399
  for (const functionConfig of this.graphEquations) {
378
400
  try {
401
+ const compiledExpression = this.compileFunctionExpression(functionConfig.equation);
379
402
  const path = new jsvgPath();
380
403
  path.setStrokeColor(this.getAllowedColor(functionConfig.color));
381
404
  path.setStrokeWidth(functionConfig.strokeWidth);
382
- this.graphFunctionWithDomain(path, functionConfig.equation, functionConfig.domain);
405
+ this.graphFunctionWithDomain(path, functionConfig.domain, compiledExpression);
383
406
  holder.addChild(path);
384
407
 
408
+ const entry = {
409
+ functionConfig,
410
+ compiledExpression,
411
+ path,
412
+ label: null
413
+ };
414
+
385
415
  if (functionConfig.label) {
386
- this.addFunctionLabel(holder, functionConfig);
416
+ entry.label = this.addFunctionLabel(holder, functionConfig, compiledExpression);
387
417
  }
418
+
419
+ this.renderedFunctionEntries.push(entry);
388
420
  } catch (e) {
389
421
  console.warn(`Failed to graph equation: ${functionConfig.equation}`, e);
390
422
  }
@@ -399,25 +431,28 @@ export class omdCoordinatePlane extends jsvgGroup {
399
431
  return expression;
400
432
  }
401
433
 
402
- graphFunctionWithDomain(pathObject, functionString, domain) {
403
- pathObject.clearPoints();
404
-
405
- const expression = this._extractExpression(functionString);
434
+ compileFunctionExpression(functionString) {
435
+ return math.compile(this._extractExpression(functionString));
436
+ }
406
437
 
407
- const compiledExpression = math.compile(expression);
438
+ graphFunctionWithDomain(pathObject, domain, compiledExpression) {
439
+ pathObject.clearPoints();
408
440
 
409
441
  const leftLimit = (domain && typeof domain.min === 'number') ? domain.min : this.xMin;
410
442
  const rightLimit = (domain && typeof domain.max === 'number') ? domain.max : this.xMax;
411
443
 
412
- const step = Math.abs(rightLimit - leftLimit) / 1000;
413
- for (let x = leftLimit; x <= rightLimit; x += step) {
414
- const y = compiledExpression.evaluate({ x });
444
+ const pointCount = this.shouldShowInteractiveControls() ? 400 : 1000;
445
+ const span = rightLimit - leftLimit;
446
+ for (let i = 0; i <= pointCount; i++) {
447
+ const x = leftLimit + (span * i) / pointCount;
448
+ const y = compiledExpression.evaluate(this.getEvaluationScope({ x }));
449
+ if (!Number.isFinite(y)) continue;
415
450
  pathObject.addPoint(this.toGraphPixelX(x), this.toGraphPixelY(y));
416
451
  }
417
452
  pathObject.updatePath();
418
453
  }
419
454
 
420
- addFunctionLabel(holder, functionConfig) {
455
+ addFunctionLabel(holder, functionConfig, compiledExpression) {
421
456
  const labelText = String(functionConfig.label);
422
457
  const fontSize = 12;
423
458
  const padding = 6;
@@ -433,15 +468,18 @@ export class omdCoordinatePlane extends jsvgGroup {
433
468
  label.setVerticalCentering();
434
469
  label.setFontWeight(500);
435
470
 
471
+ this.updateFunctionLabelPosition(label, functionConfig, compiledExpression);
472
+ holder.addChild(label);
473
+ return label;
474
+ }
475
+
476
+ updateFunctionLabelPosition(label, functionConfig, compiledExpression) {
436
477
  const anchorX = (typeof functionConfig.labelAtX === 'number')
437
478
  ? functionConfig.labelAtX
438
479
  : this.xMin + (this.xMax - this.xMin) * 0.1;
439
480
 
440
481
  let anchorY = this.yMin + (this.yMax - this.yMin) * 0.1;
441
- const equationBody = this._extractExpression(functionConfig.equation);
442
-
443
- const compiled = math.compile(equationBody);
444
- const yVal = compiled.evaluate({ x: anchorX });
482
+ const yVal = compiledExpression.evaluate(this.getEvaluationScope({ x: anchorX }));
445
483
  anchorY = yVal;
446
484
 
447
485
  const xPx = this.toGraphPixelX(anchorX);
@@ -469,7 +507,6 @@ export class omdCoordinatePlane extends jsvgGroup {
469
507
  }
470
508
 
471
509
  label.setPosition(finalX, finalY);
472
- holder.addChild(label);
473
510
  }
474
511
 
475
512
  drawLineSegments(holder) {
@@ -530,6 +567,339 @@ export class omdCoordinatePlane extends jsvgGroup {
530
567
  }
531
568
  }
532
569
 
570
+ shouldShowInteractiveControls() {
571
+ return this.interactive === true && this.variables.length > 0;
572
+ }
573
+
574
+ getInteractiveControlHeight() {
575
+ return 18 + this.variables.length * 38;
576
+ }
577
+
578
+ normalizeInteractiveVariables(variables) {
579
+ if (!Array.isArray(variables)) return [];
580
+
581
+ return variables
582
+ .map((variable, index) => this.normalizeInteractiveVariable(variable, index))
583
+ .filter(Boolean);
584
+ }
585
+
586
+ normalizeInteractiveVariable(variable, index) {
587
+ const source = (typeof variable === "string")
588
+ ? { name: variable }
589
+ : (variable && typeof variable === "object" ? variable : null);
590
+
591
+ if (!source) return null;
592
+
593
+ const name = String(
594
+ source.name ??
595
+ source.variable ??
596
+ source.id ??
597
+ ""
598
+ ).trim();
599
+
600
+ if (!name) return null;
601
+
602
+ let min = Number.isFinite(source.min) ? source.min : -10;
603
+ let max = Number.isFinite(source.max) ? source.max : 10;
604
+ if (max < min) {
605
+ const tmp = min;
606
+ min = max;
607
+ max = tmp;
608
+ }
609
+ if (max === min) {
610
+ max = min + 1;
611
+ }
612
+
613
+ const step = (Number.isFinite(source.step) && source.step > 0) ? source.step : 0.1;
614
+ const fallbackValue = (min <= 0 && max >= 0) ? 0 : min;
615
+ const initialValue = this.clampInteractiveValue(
616
+ Number.isFinite(source.value) ? source.value : fallbackValue,
617
+ min,
618
+ max
619
+ );
620
+
621
+ return {
622
+ name,
623
+ label: String(source.label ?? name),
624
+ min,
625
+ max,
626
+ step,
627
+ value: initialValue,
628
+ color: source.color ?? null,
629
+ graphIndex: Number.isInteger(source.graphIndex)
630
+ ? source.graphIndex
631
+ : (Number.isInteger(source.equationIndex) ? source.equationIndex : null),
632
+ order: index
633
+ };
634
+ }
635
+
636
+ clampInteractiveValue(value, min, max) {
637
+ return Math.max(min, Math.min(max, value));
638
+ }
639
+
640
+ snapInteractiveValue(value, variable) {
641
+ const step = variable.step || 0.1;
642
+ const snapped = variable.min + Math.round((value - variable.min) / step) * step;
643
+ const decimals = this.getStepPrecision(step);
644
+ const rounded = Number(snapped.toFixed(decimals));
645
+ return this.clampInteractiveValue(rounded, variable.min, variable.max);
646
+ }
647
+
648
+ getStepPrecision(step) {
649
+ const stepText = String(step);
650
+ if (!stepText.includes(".")) return 0;
651
+ return stepText.split(".")[1].length;
652
+ }
653
+
654
+ getInteractiveVariableValue(variable) {
655
+ if (!variable) return 0;
656
+ if (Number.isFinite(variable.currentValue)) return variable.currentValue;
657
+ if (Number.isFinite(variable.value)) return variable.value;
658
+ return 0;
659
+ }
660
+
661
+ getEvaluationScope(extraScope = {}) {
662
+ const scope = { ...extraScope };
663
+ for (const variable of this.variables) {
664
+ scope[variable.name] = this.getInteractiveVariableValue(variable);
665
+ }
666
+ return scope;
667
+ }
668
+
669
+ createInteractiveControls(parentSvg) {
670
+ const controlsGroup = this.createSvgElement("g", {
671
+ "data-omd-interactive-controls": "true"
672
+ });
673
+
674
+ const graphBottom = this.paddingTop + this.graphHeight;
675
+ const startY = graphBottom + this.basePaddingBottom + 8;
676
+ const labelX = this.paddingLeft;
677
+ const valueX = this.width - this.paddingRight;
678
+ const trackStart = this.paddingLeft + 36;
679
+ const trackEnd = this.width - this.paddingRight - 56;
680
+ const trackWidth = Math.max(40, trackEnd - trackStart);
681
+
682
+ this.sliderControlRows = this.variables.map((variable, index) => {
683
+ const color = this.resolveInteractiveVariableColor(variable);
684
+ const rowY = startY + index * 38;
685
+ const rowGroup = this.createSvgElement("g", {
686
+ transform: `translate(0, ${rowY})`,
687
+ "data-omd-variable": variable.name
688
+ });
689
+
690
+ const label = this.createSvgElement("text", {
691
+ x: labelX,
692
+ y: 4,
693
+ fill: color,
694
+ "font-size": 13,
695
+ "font-weight": 700,
696
+ "dominant-baseline": "middle"
697
+ });
698
+ label.textContent = variable.label;
699
+
700
+ const track = this.createSvgElement("line", {
701
+ x1: trackStart,
702
+ y1: 0,
703
+ x2: trackStart + trackWidth,
704
+ y2: 0,
705
+ stroke: "#d1d5db",
706
+ "stroke-width": 6,
707
+ "stroke-linecap": "round"
708
+ });
709
+
710
+ const activeTrack = this.createSvgElement("line", {
711
+ x1: trackStart,
712
+ y1: 0,
713
+ x2: trackStart,
714
+ y2: 0,
715
+ stroke: color,
716
+ "stroke-width": 6,
717
+ "stroke-linecap": "round"
718
+ });
719
+
720
+ const thumb = this.createSvgElement("circle", {
721
+ cx: trackStart,
722
+ cy: 0,
723
+ r: 8,
724
+ fill: color,
725
+ stroke: "#ffffff",
726
+ "stroke-width": 2
727
+ });
728
+
729
+ const valueText = this.createSvgElement("text", {
730
+ x: valueX,
731
+ y: 4,
732
+ fill: color,
733
+ "font-size": 12,
734
+ "font-weight": 600,
735
+ "dominant-baseline": "middle",
736
+ "text-anchor": "end"
737
+ });
738
+
739
+ const hitArea = this.createSvgElement("rect", {
740
+ x: trackStart - 10,
741
+ y: -14,
742
+ width: trackWidth + 20,
743
+ height: 28,
744
+ fill: "transparent",
745
+ "pointer-events": "all",
746
+ rx: 14
747
+ });
748
+ hitArea.style.cursor = "pointer";
749
+
750
+ rowGroup.appendChild(label);
751
+ rowGroup.appendChild(track);
752
+ rowGroup.appendChild(activeTrack);
753
+ rowGroup.appendChild(thumb);
754
+ rowGroup.appendChild(valueText);
755
+ rowGroup.appendChild(hitArea);
756
+ controlsGroup.appendChild(rowGroup);
757
+
758
+ const row = {
759
+ variable,
760
+ color,
761
+ label,
762
+ track,
763
+ activeTrack,
764
+ thumb,
765
+ valueText,
766
+ hitArea,
767
+ trackStart,
768
+ trackEnd: trackStart + trackWidth
769
+ };
770
+
771
+ this.attachSliderInteraction(row);
772
+ this.updateSliderRow(row);
773
+ return row;
774
+ });
775
+
776
+ parentSvg.appendChild(controlsGroup);
777
+ }
778
+
779
+ attachSliderInteraction(row) {
780
+ const updateFromPointer = (event) => {
781
+ const localX = this.clientToLocalSvgX(event.clientX);
782
+ const ratio = (localX - row.trackStart) / (row.trackEnd - row.trackStart);
783
+ const unclampedValue = row.variable.min + ratio * (row.variable.max - row.variable.min);
784
+ const nextValue = this.snapInteractiveValue(unclampedValue, row.variable);
785
+ row.variable.currentValue = nextValue;
786
+ row.variable.value = nextValue;
787
+ this.refreshInteractiveElements();
788
+ };
789
+
790
+ row.hitArea.addEventListener("pointerdown", (event) => {
791
+ event.preventDefault();
792
+ event.stopPropagation();
793
+
794
+ updateFromPointer(event);
795
+ row.hitArea.setPointerCapture?.(event.pointerId);
796
+
797
+ const handleMove = (moveEvent) => {
798
+ moveEvent.preventDefault();
799
+ moveEvent.stopPropagation();
800
+ updateFromPointer(moveEvent);
801
+ };
802
+
803
+ const stop = (endEvent) => {
804
+ endEvent?.preventDefault?.();
805
+ endEvent?.stopPropagation?.();
806
+ row.hitArea.removeEventListener("pointermove", handleMove);
807
+ row.hitArea.removeEventListener("pointerup", stop);
808
+ row.hitArea.removeEventListener("pointercancel", stop);
809
+ try {
810
+ row.hitArea.releasePointerCapture?.(event.pointerId);
811
+ } catch {}
812
+ };
813
+
814
+ row.hitArea.addEventListener("pointermove", handleMove);
815
+ row.hitArea.addEventListener("pointerup", stop);
816
+ row.hitArea.addEventListener("pointercancel", stop);
817
+ });
818
+ }
819
+
820
+ refreshInteractiveElements() {
821
+ for (const entry of this.renderedFunctionEntries) {
822
+ try {
823
+ this.graphFunctionWithDomain(entry.path, entry.functionConfig.domain, entry.compiledExpression);
824
+ if (entry.label) {
825
+ this.updateFunctionLabelPosition(entry.label, entry.functionConfig, entry.compiledExpression);
826
+ }
827
+ } catch (error) {
828
+ console.warn(`Failed to refresh equation: ${entry.functionConfig?.equation}`, error);
829
+ }
830
+ }
831
+
832
+ for (const row of this.sliderControlRows) {
833
+ this.updateSliderRow(row);
834
+ }
835
+ }
836
+
837
+ updateSliderRow(row) {
838
+ const currentValue = this.getInteractiveVariableValue(row.variable);
839
+ const ratio = (currentValue - row.variable.min) / (row.variable.max - row.variable.min);
840
+ const clampedRatio = Math.max(0, Math.min(1, ratio));
841
+ const thumbX = row.trackStart + (row.trackEnd - row.trackStart) * clampedRatio;
842
+
843
+ row.activeTrack.setAttribute("x2", String(thumbX));
844
+ row.thumb.setAttribute("cx", String(thumbX));
845
+ row.valueText.textContent = this.formatInteractiveValue(currentValue, row.variable.step);
846
+ }
847
+
848
+ formatInteractiveValue(value, step) {
849
+ const decimals = Math.min(4, Math.max(0, this.getStepPrecision(step)));
850
+ const rounded = Number(value.toFixed(decimals));
851
+ return `${rounded}`;
852
+ }
853
+
854
+ resolveInteractiveVariableColor(variable) {
855
+ if (variable.color) {
856
+ return this.getAllowedColor(variable.color);
857
+ }
858
+
859
+ const graphIndex = variable.graphIndex;
860
+ if (Number.isInteger(graphIndex) && this.graphEquations[graphIndex]?.color) {
861
+ return this.getAllowedColor(this.graphEquations[graphIndex].color);
862
+ }
863
+
864
+ const matchedEquation = this.graphEquations.find((graphConfig) =>
865
+ this.equationContainsVariable(graphConfig?.equation, variable.name)
866
+ );
867
+
868
+ if (matchedEquation?.color) {
869
+ return this.getAllowedColor(matchedEquation.color);
870
+ }
871
+
872
+ return this.getAllowedColor("#2563eb");
873
+ }
874
+
875
+ equationContainsVariable(equation, variableName) {
876
+ if (!equation || !variableName) return false;
877
+ const escapedName = variableName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
878
+ const variableRegex = new RegExp(`(^|[^A-Za-z0-9_])${escapedName}([^A-Za-z0-9_]|$)`);
879
+ return variableRegex.test(String(equation));
880
+ }
881
+
882
+ createSvgElement(tagName, attributes = {}) {
883
+ const element = document.createElementNS("http://www.w3.org/2000/svg", tagName);
884
+ for (const [key, value] of Object.entries(attributes)) {
885
+ if (value !== undefined && value !== null) {
886
+ element.setAttribute(key, String(value));
887
+ }
888
+ }
889
+ return element;
890
+ }
891
+
892
+ clientToLocalSvgX(clientX) {
893
+ const rect = this.svgObject.getBoundingClientRect();
894
+ const viewBox = (this.svgObject.getAttribute("viewBox") || "0 0 0 0")
895
+ .split(/\s+/)
896
+ .map(Number);
897
+ const [viewBoxX = 0, , viewBoxWidth = 0] = viewBox;
898
+ if (!rect.width || !viewBoxWidth) return 0;
899
+
900
+ return viewBoxX + ((clientX - rect.left) / rect.width) * viewBoxWidth;
901
+ }
902
+
533
903
  // Background customization methods
534
904
  setBackgroundColor(color) {
535
905
  this.backgroundColor = color;
@@ -558,4 +928,4 @@ export class omdCoordinatePlane extends jsvgGroup {
558
928
  if (options.show !== undefined) this.showBackground = options.show;
559
929
  this.updateLayout();
560
930
  }
561
- }
931
+ }
package/src/omdShapes.js CHANGED
@@ -2,6 +2,39 @@
2
2
  import { omdColor } from "./omdColor.js";
3
3
  import { jsvgGroup, jsvgPath, jsvgLine, jsvgTextLine, jsvgEllipse } from "@teachinglab/jsvg";
4
4
 
5
+ const DEFAULT_SHAPE_PADDING = 10;
6
+ const LABEL_SHAPE_PADDING = 38;
7
+
8
+ function getShapePadding(showLabels) {
9
+ return showLabels ? LABEL_SHAPE_PADDING : DEFAULT_SHAPE_PADDING;
10
+ }
11
+
12
+ function getClosedPolygonVertices(shapePath) {
13
+ const points = Array.isArray(shapePath?.points) ? shapePath.points : [];
14
+ if (points.length <= 1) return points.slice();
15
+
16
+ const first = points[0];
17
+ const last = points[points.length - 1];
18
+ const isClosed = first.x === last.x && first.y === last.y;
19
+ return isClosed ? points.slice(0, -1) : points.slice();
20
+ }
21
+
22
+ function getPolygonCentroid(points) {
23
+ if (!points.length) return { x: 0, y: 0 };
24
+
25
+ let sumX = 0;
26
+ let sumY = 0;
27
+ for (const point of points) {
28
+ sumX += point.x;
29
+ sumY += point.y;
30
+ }
31
+
32
+ return {
33
+ x: sumX / points.length,
34
+ y: sumY / points.length
35
+ };
36
+ }
37
+
5
38
  export class omdRightTriangle extends jsvgGroup
6
39
  {
7
40
  constructor()
@@ -69,8 +102,9 @@ export class omdRightTriangle extends jsvgGroup
69
102
  updateLayout()
70
103
  {
71
104
  // Center the triangle within the viewBox
72
- const offsetX = 10; // Left margin
73
- const offsetY = this.unitScale * this.verticalLeg + 10; // Bottom margin
105
+ const padding = getShapePadding(this.showLabels);
106
+ const offsetX = padding;
107
+ const offsetY = this.unitScale * this.verticalLeg + padding;
74
108
 
75
109
  this.shapePath.clearPoints();
76
110
  this.shapePath.addPoint(offsetX, offsetY);
@@ -92,8 +126,8 @@ export class omdRightTriangle extends jsvgGroup
92
126
  }
93
127
 
94
128
  // Set viewBox to center the shape
95
- this.width = this.unitScale * this.horizontalLeg + 20;
96
- this.height = this.unitScale * this.verticalLeg + 20;
129
+ this.width = this.unitScale * this.horizontalLeg + padding * 2;
130
+ this.height = this.unitScale * this.verticalLeg + padding * 2;
97
131
  this.svgObject.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`);
98
132
  }
99
133
  }
@@ -150,8 +184,9 @@ export class omdIsoscelesTriangle extends jsvgGroup
150
184
 
151
185
  const baseWidth = this.unitScale * this.triangleBase;
152
186
  const triangleHeight = this.unitScale * this.triangleHeight;
153
- const offsetX = 10;
154
- const offsetY = triangleHeight + 10;
187
+ const padding = getShapePadding(this.showLabels);
188
+ const offsetX = padding;
189
+ const offsetY = triangleHeight + padding;
155
190
 
156
191
  this.shapePath.addPoint( offsetX, offsetY );
157
192
  this.shapePath.addPoint( offsetX + baseWidth, offsetY );
@@ -169,8 +204,8 @@ export class omdIsoscelesTriangle extends jsvgGroup
169
204
  }
170
205
 
171
206
  // Set dimensions and viewBox for API compatibility
172
- this.width = this.unitScale * this.triangleBase + 20;
173
- this.height = this.unitScale * this.triangleHeight + 20;
207
+ this.width = this.unitScale * this.triangleBase + padding * 2;
208
+ this.height = this.unitScale * this.triangleHeight + padding * 2;
174
209
  this.svgObject.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`);
175
210
  }
176
211
  }
@@ -222,8 +257,9 @@ export class omdRectangle extends jsvgGroup
222
257
  updateLayout()
223
258
  {
224
259
  // Center the rectangle within the viewBox
225
- const offsetX = 10;
226
- const offsetY = this.unitScale * this.rectHeight + 10;
260
+ const padding = getShapePadding(this.showLabels);
261
+ const offsetX = padding;
262
+ const offsetY = this.unitScale * this.rectHeight + padding;
227
263
 
228
264
  this.shapePath.clearPoints();
229
265
  this.shapePath.addPoint(offsetX, offsetY);
@@ -245,8 +281,8 @@ export class omdRectangle extends jsvgGroup
245
281
  }
246
282
 
247
283
  // Set viewBox
248
- this.width = this.unitScale * this.rectWidth + 20;
249
- this.height = this.unitScale * this.rectHeight + 20;
284
+ this.width = this.unitScale * this.rectWidth + padding * 2;
285
+ this.height = this.unitScale * this.rectHeight + padding * 2;
250
286
  this.svgObject.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`);
251
287
  }
252
288
  }
@@ -367,19 +403,37 @@ export class omdCircle extends jsvgGroup
367
403
  updateLayout()
368
404
  {
369
405
  const diameter = 2.0 * this.radius * this.unitScale;
406
+ const radiusPx = this.radius * this.unitScale;
407
+ const centerX = radiusPx + 10;
408
+ const centerY = radiusPx + 10;
370
409
 
371
410
  this.shapePath.setWidthAndHeight( diameter, diameter );
372
- this.shapePath.setPosition( this.radius * this.unitScale + 10, this.radius * this.unitScale + 10 );
411
+ this.shapePath.setPosition( centerX, centerY );
373
412
 
374
413
  this.labelsHolder.removeAllChildren();
375
414
  if ( this.showLabels )
376
415
  {
416
+ const angleRadians = -Math.PI / 6;
417
+ const endX = centerX + Math.cos(angleRadians) * radiusPx * 0.82;
418
+ const endY = centerY + Math.sin(angleRadians) * radiusPx * 0.82;
419
+
420
+ const radiusLine = new jsvgLine();
421
+ radiusLine.setStrokeColor("black");
422
+ radiusLine.setStrokeWidth(1.5);
423
+ radiusLine.setEndpoints(centerX, centerY, endX, endY);
424
+ this.labelsHolder.addChild(radiusLine);
425
+
377
426
  const label = new jsvgTextLine();
378
427
  label.setAlignment("center");
379
428
  label.setFontFamily( "Albert Sans" );
380
429
  label.setFontColor( "black" );
381
430
  label.setFontSize( 12 );
382
- label.setPosition( this.radius * this.unitScale + 10, this.radius * this.unitScale + 14 );
431
+ label.svgObject.setAttribute('dominant-baseline', 'middle');
432
+ label.setRotation(angleRadians * 180 / Math.PI);
433
+ label.setPosition(
434
+ (centerX + endX) * 0.5 + 3,
435
+ (centerY + endY) * 0.5 - 4
436
+ );
383
437
  label.setText( `r=${this.radius}` );
384
438
  this.labelsHolder.addChild( label );
385
439
  }
@@ -440,6 +494,10 @@ export class omdRegularPolygon extends jsvgGroup
440
494
  updateLayout()
441
495
  {
442
496
  this.shapePath.clearPoints();
497
+ const radiusPx = this.radius * this.unitScale;
498
+ const padding = this.showLabels ? 48 : 14;
499
+ const centerX = radiusPx + padding;
500
+ const centerY = radiusPx + padding;
443
501
 
444
502
  var angleOffset = 0;
445
503
  if ( this.numberOfSides % 2 == 1 )
@@ -453,8 +511,8 @@ export class omdRegularPolygon extends jsvgGroup
453
511
  {
454
512
  var A = -2.0 * Math.PI / this.numberOfSides * i;
455
513
  A += angleOffset;
456
- var pX = Math.cos(A) * this.radius * this.unitScale;
457
- var pY = Math.sin(A) * this.radius * this.unitScale;
514
+ var pX = centerX + Math.cos(A) * radiusPx;
515
+ var pY = centerY + Math.sin(A) * radiusPx;
458
516
  this.shapePath.addPoint( pX, pY );
459
517
  }
460
518
 
@@ -472,9 +530,9 @@ export class omdRegularPolygon extends jsvgGroup
472
530
  }
473
531
 
474
532
  // Set dimensions and viewBox for API compatibility
475
- this.width = 2.0 * this.radius * this.unitScale + 20;
476
- this.height = 2.0 * this.radius * this.unitScale + 20;
477
- this.svgObject.setAttribute('viewBox', `-${this.width/2} -${this.height/2} ${this.width} ${this.height}`);
533
+ this.width = 2.0 * radiusPx + padding * 2;
534
+ this.height = 2.0 * radiusPx + padding * 2;
535
+ this.svgObject.setAttribute('viewBox', `0 0 ${this.width} ${this.height}`);
478
536
  }
479
537
  }
480
538
 
@@ -489,76 +547,77 @@ export class omdShapeLabelSet extends jsvgGroup
489
547
 
490
548
  initializeWithShapePath( shapePath, labelTextArray=[] )
491
549
  {
492
- // this assumes counterclockwise path
550
+ const polygonPoints = getClosedPolygonVertices(shapePath);
551
+ const centroid = getPolygonCentroid(polygonPoints);
493
552
 
494
553
  // create lines
495
- for( var i=0; i<shapePath.points.length-1; i++ )
554
+ for (let i = 0; i < polygonPoints.length; i++)
496
555
  {
497
- if ( i >= labelTextArray.length || labelTextArray[i].length == 0 )
556
+ if ( i >= labelTextArray.length )
557
+ continue;
558
+
559
+ const labelValue = labelTextArray[i];
560
+ const labelString = String(labelValue ?? "");
561
+ if ( labelString.length === 0 )
498
562
  continue;
499
563
 
500
564
  // get points
501
- var point0 = shapePath.points[i];
502
- var x0 = point0.x;
503
- var y0 = point0.y;
565
+ const point0 = polygonPoints[i];
566
+ const x0 = point0.x;
567
+ const y0 = point0.y;
504
568
 
505
- var point1 = shapePath.points[i+1];
506
- var x1 = point1.x;
507
- var y1 = point1.y;
569
+ const point1 = polygonPoints[(i + 1) % polygonPoints.length];
570
+ const x1 = point1.x;
571
+ const y1 = point1.y;
508
572
 
509
573
  // get normalized vector
510
- var dX = x1 - x0;
511
- var dY = y1 - y0;
512
- var L = Math.sqrt( dX*dX + dY*dY );
574
+ let dX = x1 - x0;
575
+ let dY = y1 - y0;
576
+ const L = Math.sqrt( dX*dX + dY*dY );
513
577
  dX /= L;
514
578
  dY /= L;
515
579
 
516
- // get normal
517
- var normalX = -1.0 * dY;
518
- var normalY = dX;
580
+ // get outward normal based on the polygon centroid rather than path winding
581
+ const centerX = (x0 + x1) / 2.0;
582
+ const centerY = (y0 + y1) / 2.0;
583
+ let normalX = -1.0 * dY;
584
+ let normalY = dX;
585
+ const toMidX = centerX - centroid.x;
586
+ const toMidY = centerY - centroid.y;
587
+ if ( toMidX * normalX + toMidY * normalY < 0 )
588
+ {
589
+ normalX *= -1.0;
590
+ normalY *= -1.0;
591
+ }
519
592
 
520
593
  // make line
521
- var labelLine = new jsvgLine();
522
- var newLineX0 = x0 + normalX * 10.0;
523
- var newLineY0 = y0 + normalY * 10.0;
524
- var newLineX1 = x1 + normalX * 10.0;
525
- var newLineY1 = y1 + normalY * 10.0;
594
+ const labelLine = new jsvgLine();
595
+ const newLineX0 = x0 + normalX * 10.0;
596
+ const newLineY0 = y0 + normalY * 10.0;
597
+ const newLineX1 = x1 + normalX * 10.0;
598
+ const newLineY1 = y1 + normalY * 10.0;
526
599
  labelLine.setEndpoints( newLineX0, newLineY0, newLineX1, newLineY1 );
527
600
  this.addChild( labelLine );
528
601
 
529
- // center point
530
- var centerX = (x0 + x1) / 2.0;
531
- var centerY = (y0 + y1) / 2.0;
532
-
533
- var angle = Math.atan2( dY, dX );
534
- angle *= 180 / Math.PI;
535
- angle += 180.0;
536
- angle = angle % 360.0;
537
- var originalAngle = angle;
538
-
539
- // flip upside-down text
540
- var offset = 0;
541
- if ( angle > 90 && angle < 270 )
542
- {
543
- angle -= 180.0;
544
- offset += 10.0;
545
- }
602
+ let angle = Math.atan2( dY, dX ) * 180 / Math.PI;
603
+ if ( angle > 90 || angle < -90 )
604
+ angle += 180.0;
546
605
 
547
606
  // label text
548
- var textCenterX = centerX + normalX * (15.0+offset);
549
- var textCenterY = centerY + normalY * (15.0+offset);
550
- var labelText = new jsvgTextLine();
607
+ const textCenterX = centerX + normalX * 18.0;
608
+ const textCenterY = centerY + normalY * 18.0;
609
+ const labelText = new jsvgTextLine();
551
610
  labelText.setAlignment("center");
552
611
  labelText.setFontFamily( "Albert Sans" );
553
612
  labelText.setFontColor( "black" );
554
613
  labelText.setFontSize( 12 );
555
614
  labelText.setPosition( textCenterX, textCenterY );
556
615
  labelText.setRotation( angle );
616
+ labelText.svgObject.setAttribute('dominant-baseline', 'middle');
557
617
  this.addChild( labelText );
558
618
 
559
- labelText.setText( labelTextArray[i] );
619
+ labelText.setText( labelString );
560
620
  }
561
621
  }
562
622
 
563
623
  }
564
-