@teachinglab/omd 0.7.31 → 0.7.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teachinglab/omd",
3
- "version": "0.7.31",
3
+ "version": "0.7.33",
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 || 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(this.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
+ }