@teachinglab/omd 0.7.17 → 0.7.18

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.
@@ -48,7 +48,26 @@ export class CanvasConfig {
48
48
  danger: '#dc3545',
49
49
  ...options.theme
50
50
  };
51
-
51
+
52
+ // Selection / resize-handle styling
53
+ this.selection = {
54
+ // Selection border
55
+ border: {
56
+ color: options.selection?.border?.color ?? '#007bff',
57
+ width: options.selection?.border?.width ?? 2,
58
+ dasharray: options.selection?.border?.dasharray ?? '4,2',
59
+ cornerRadius: options.selection?.border?.cornerRadius ?? undefined,
60
+ },
61
+ // Resize handles
62
+ handle: {
63
+ size: options.selection?.handle?.size ?? 8,
64
+ color: options.selection?.handle?.color ?? '#007bff',
65
+ strokeColor: options.selection?.handle?.strokeColor ?? '#ffffff',
66
+ strokeWidth: options.selection?.handle?.strokeWidth ?? 1,
67
+ cornerRadius: options.selection?.handle?.cornerRadius ?? 1,
68
+ },
69
+ };
70
+
52
71
  // Validate configuration
53
72
  this._validate();
54
73
  }
@@ -156,7 +175,8 @@ export class CanvasConfig {
156
175
  enableKeyboardShortcuts: this.enableKeyboardShortcuts,
157
176
  enableMultiTouch: this.enableMultiTouch,
158
177
  tools: JSON.parse(JSON.stringify(this.tools)),
159
- theme: { ...this.theme }
178
+ theme: { ...this.theme },
179
+ selection: JSON.parse(JSON.stringify(this.selection))
160
180
  });
161
181
  }
162
182
 
@@ -178,7 +198,8 @@ export class CanvasConfig {
178
198
  enableKeyboardShortcuts: this.enableKeyboardShortcuts,
179
199
  enableMultiTouch: this.enableMultiTouch,
180
200
  tools: this.tools,
181
- theme: this.theme
201
+ theme: this.theme,
202
+ selection: this.selection
182
203
  };
183
204
  }
184
205
 
@@ -199,6 +199,13 @@ export class omdCanvas {
199
199
  if (this.config.enableFocusFrames) {
200
200
  this.focusFrameManager = new FocusFrameManager(this);
201
201
  }
202
+
203
+ // Apply any selection styles defined in config to the ResizeHandleManager
204
+ // (PointerTool registers the manager on canvas.resizeHandleManager during its constructor)
205
+ if (this.resizeHandleManager && this.config.selection) {
206
+ this.resizeHandleManager.setSelectionStyle(this.config.selection.border);
207
+ this.resizeHandleManager.setHandleStyle(this.config.selection.handle);
208
+ }
202
209
  }
203
210
 
204
211
  /**
@@ -547,4 +554,42 @@ export class omdCanvas {
547
554
  this.isDestroyed = true;
548
555
  this.emit('destroyed');
549
556
  }
550
- }
557
+ /**
558
+ * Style the selection border shown when an OMD visual is selected.
559
+ * Can be called at any time; changes take effect immediately.
560
+ * @param {Object} style
561
+ * @param {string} [style.color] - Border stroke colour (e.g. '#007bff')
562
+ * @param {number} [style.width] - Border stroke width in px
563
+ * @param {string} [style.dasharray] - SVG stroke-dasharray (e.g. '4,2' or 'none')
564
+ * @param {number} [style.cornerRadius] - Border corner radius
565
+ */
566
+ setSelectionStyle(style = {}) {
567
+ if (this.resizeHandleManager) {
568
+ this.resizeHandleManager.setSelectionStyle(style);
569
+ }
570
+ // Persist into config so clones / serialisation reflect the change
571
+ if (this.config.selection?.border) {
572
+ Object.assign(this.config.selection.border, style);
573
+ }
574
+ }
575
+
576
+ /**
577
+ * Style the resize handles shown when an OMD visual is selected.
578
+ * Can be called at any time; changes take effect immediately.
579
+ * @param {Object} style
580
+ * @param {number} [style.size] - Handle size in px
581
+ * @param {string} [style.color] - Handle fill colour
582
+ * @param {string} [style.strokeColor] - Handle border colour
583
+ * @param {number} [style.strokeWidth] - Handle border width in px
584
+ * @param {number} [style.cornerRadius] - Handle corner radius (0 = square, size/2 = circle)
585
+ */
586
+ setHandleStyle(style = {}) {
587
+ if (this.resizeHandleManager) {
588
+ this.resizeHandleManager.setHandleStyle(style);
589
+ }
590
+ // Persist into config
591
+ if (this.config.selection?.handle) {
592
+ Object.assign(this.config.selection.handle, style);
593
+ }
594
+ }
595
+ }
@@ -24,7 +24,7 @@ export class ResizeHandleManager {
24
24
  // Resize constraints
25
25
  this.minSize = 20;
26
26
  this.maxSize = 800;
27
- this.maintainAspectRatio = false; // Can be toggled with shift key
27
+ this.maintainAspectRatio = true; // Always maintain aspect ratio by default
28
28
  }
29
29
 
30
30
  /**
@@ -195,25 +195,23 @@ export class ResizeHandleManager {
195
195
  break;
196
196
  }
197
197
 
198
- // Maintain aspect ratio if requested
198
+ // Maintain aspect ratio use the larger delta to drive the resize
199
199
  if (this.maintainAspectRatio) {
200
200
  const aspectRatio = startWidth / startHeight;
201
-
202
- if (handle.type.includes('e') || handle.type.includes('w')) {
203
- // Width-based resize
201
+ const widthChange = Math.abs(newWidth - startWidth);
202
+ const heightChange = Math.abs(newHeight - startHeight);
203
+
204
+ if (widthChange >= heightChange) {
204
205
  newHeight = newWidth / aspectRatio;
205
- } else if (handle.type.includes('n') || handle.type.includes('s')) {
206
- // Height-based resize
207
- newWidth = newHeight * aspectRatio;
206
+ // Correct offsetY for top-anchored corners
207
+ if (handle.type.includes('n')) {
208
+ offsetY = startHeight - newHeight;
209
+ }
208
210
  } else {
209
- // Corner resize - use the dimension with larger change
210
- const widthChange = Math.abs(newWidth - startWidth);
211
- const heightChange = Math.abs(newHeight - startHeight);
212
-
213
- if (widthChange > heightChange) {
214
- newHeight = newWidth / aspectRatio;
215
- } else {
216
- newWidth = newHeight * aspectRatio;
211
+ newWidth = newHeight * aspectRatio;
212
+ // Correct offsetX for left-anchored corners
213
+ if (handle.type.includes('w')) {
214
+ offsetX = startWidth - newWidth;
217
215
  }
218
216
  }
219
217
  }
@@ -264,6 +262,68 @@ export class ResizeHandleManager {
264
262
  this.resizeData = null;
265
263
  }
266
264
 
265
+ /**
266
+ * Style the selection border.
267
+ * @param {Object} style
268
+ * @param {string} [style.color] - Stroke colour of the border (e.g. '#007bff')
269
+ * @param {number} [style.width] - Stroke width in px
270
+ * @param {string} [style.dasharray] - SVG stroke-dasharray value (e.g. '4,2' or 'none')
271
+ * @param {number} [style.cornerRadius]- rx/ry corner radius of the border rect
272
+ */
273
+ setSelectionStyle({ color, width, dasharray, cornerRadius } = {}) {
274
+ if (color !== undefined) this.selectionBorderColor = color;
275
+ if (width !== undefined) this.selectionBorderWidth = width;
276
+ if (dasharray !== undefined) this.selectionBorderDasharray = dasharray;
277
+ if (cornerRadius !== undefined) this.selectionBorderCornerRadius = cornerRadius;
278
+
279
+ // Re-apply to live border if one exists
280
+ if (this.selectionBorder) {
281
+ this.selectionBorder.setAttribute('stroke', this.selectionBorderColor);
282
+ this.selectionBorder.setAttribute('stroke-width', this.selectionBorderWidth);
283
+ this.selectionBorder.setAttribute('stroke-dasharray',
284
+ this.selectionBorderDasharray !== undefined ? this.selectionBorderDasharray : '4,2');
285
+ if (this.selectionBorderCornerRadius !== undefined) {
286
+ this.selectionBorder.setAttribute('rx', this.selectionBorderCornerRadius);
287
+ this.selectionBorder.setAttribute('ry', this.selectionBorderCornerRadius);
288
+ }
289
+ }
290
+ }
291
+
292
+ /**
293
+ * Style the resize handles.
294
+ * @param {Object} style
295
+ * @param {number} [style.size] - Handle square size in px
296
+ * @param {string} [style.color] - Fill colour
297
+ * @param {string} [style.strokeColor] - Border colour
298
+ * @param {number} [style.strokeWidth] - Border width
299
+ * @param {number} [style.cornerRadius] - rx/ry corner radius (0 = square, size/2 = circle)
300
+ */
301
+ setHandleStyle({ size, color, strokeColor, strokeWidth, cornerRadius } = {}) {
302
+ if (size !== undefined) this.handleSize = size;
303
+ if (color !== undefined) this.handleColor = color;
304
+ if (strokeColor !== undefined) this.handleStrokeColor = strokeColor;
305
+ if (strokeWidth !== undefined) this.handleStrokeWidth = strokeWidth;
306
+ if (cornerRadius !== undefined) this.handleCornerRadius = cornerRadius;
307
+
308
+ // Re-apply to any live handles
309
+ const radius = this.handleCornerRadius !== undefined
310
+ ? this.handleCornerRadius
311
+ : 1;
312
+ this.handles.forEach(h => {
313
+ h.element.setAttribute('width', this.handleSize);
314
+ h.element.setAttribute('height', this.handleSize);
315
+ h.element.setAttribute('fill', this.handleColor);
316
+ h.element.setAttribute('stroke', this.handleStrokeColor);
317
+ h.element.setAttribute('stroke-width', this.handleStrokeWidth);
318
+ h.element.setAttribute('rx', radius);
319
+ });
320
+
321
+ // Reposition so centres stay correct after a size change
322
+ if (this.handles.length > 0) {
323
+ this._updateHandlePositions();
324
+ }
325
+ }
326
+
267
327
  /**
268
328
  * Update handle positions for currently selected element (called externally)
269
329
  */
@@ -304,7 +364,12 @@ export class ResizeHandleManager {
304
364
  this.selectionBorder.setAttribute('fill', 'none');
305
365
  this.selectionBorder.setAttribute('stroke', this.selectionBorderColor);
306
366
  this.selectionBorder.setAttribute('stroke-width', this.selectionBorderWidth);
307
- this.selectionBorder.setAttribute('stroke-dasharray', '4,2');
367
+ this.selectionBorder.setAttribute('stroke-dasharray',
368
+ this.selectionBorderDasharray !== undefined ? this.selectionBorderDasharray : '4,2');
369
+ if (this.selectionBorderCornerRadius !== undefined) {
370
+ this.selectionBorder.setAttribute('rx', this.selectionBorderCornerRadius);
371
+ this.selectionBorder.setAttribute('ry', this.selectionBorderCornerRadius);
372
+ }
308
373
  this.selectionBorder.style.pointerEvents = 'none';
309
374
  this.selectionBorder.classList.add('omd-selection-border');
310
375
 
@@ -346,15 +411,12 @@ export class ResizeHandleManager {
346
411
  _createResizeHandles() {
347
412
  if (!this.selectedElement) return;
348
413
 
414
+ // Only corner handles — mid-edge handles would break aspect ratio
349
415
  const handleTypes = [
350
416
  { type: 'nw', pos: 'top-left' },
351
- { type: 'n', pos: 'top-center' },
352
417
  { type: 'ne', pos: 'top-right' },
353
- { type: 'e', pos: 'middle-right' },
354
418
  { type: 'se', pos: 'bottom-right' },
355
- { type: 's', pos: 'bottom-center' },
356
- { type: 'sw', pos: 'bottom-left' },
357
- { type: 'w', pos: 'middle-left' }
419
+ { type: 'sw', pos: 'bottom-left' }
358
420
  ];
359
421
 
360
422
  handleTypes.forEach(handleDef => {
@@ -371,12 +433,13 @@ export class ResizeHandleManager {
371
433
  _createHandle(type, position) {
372
434
  const handle = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
373
435
 
436
+ const cornerRadius = this.handleCornerRadius !== undefined ? this.handleCornerRadius : 1;
374
437
  handle.setAttribute('width', this.handleSize);
375
438
  handle.setAttribute('height', this.handleSize);
376
439
  handle.setAttribute('fill', this.handleColor);
377
440
  handle.setAttribute('stroke', this.handleStrokeColor);
378
441
  handle.setAttribute('stroke-width', this.handleStrokeWidth);
379
- handle.setAttribute('rx', 1);
442
+ handle.setAttribute('rx', cornerRadius);
380
443
  handle.style.cursor = this.getCursorForHandle(type);
381
444
  handle.classList.add('resize-handle', `resize-handle-${type}`);
382
445
 
@@ -972,6 +972,12 @@ export class PointerTool extends Tool {
972
972
  selectionLayer.removeChild(selectionLayer.firstChild);
973
973
  }
974
974
 
975
+ // If the selection is pure-OMD (no strokes), ResizeHandleManager already draws
976
+ // the selection border + handles — don't draw a second box on top of it.
977
+ if (this.selectedSegments.size === 0 && this.selectedOMDElements.size > 0) {
978
+ return;
979
+ }
980
+
975
981
  const bounds = this._getSelectionBounds();
976
982
  if (!bounds) return;
977
983
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teachinglab/omd",
3
- "version": "0.7.17",
3
+ "version": "0.7.18",
4
4
  "description": "omd",
5
5
  "main": "./index.js",
6
6
  "module": "./index.js",
@@ -16,7 +16,7 @@
16
16
  "omd/",
17
17
  "docs/",
18
18
  "canvas/",
19
- "jsvg/" ,
19
+ "jsvg/",
20
20
  "npm-docs/",
21
21
  "README.md"
22
22
  ],
@@ -34,7 +34,7 @@
34
34
  "dependencies": {
35
35
  "@teachinglab/jsvg": "^0.1.1",
36
36
  "mathjs": "^14.5.2",
37
- "openai": "6.6.0"
37
+ "openai": "6.6.0"
38
38
  },
39
39
  "scripts": {
40
40
  "dev": "npm run build:docs && vite",
@@ -216,7 +216,12 @@ This document provides schemas and examples for the `loadFromJSON` method used i
216
216
  "backgroundCornerRadius": "number",
217
217
  "backgroundOpacity": "number",
218
218
  "showBackground": "boolean",
219
- "alternatingRowColors": ["array"]
219
+ "alternatingRowColors": ["array - e.g. ['#EEEEEE', '#FFFFFF']; index 0 used for header row"],
220
+ "headerBackgroundColor": "string (used when alternatingRowColors is not set)",
221
+ "cellBackgroundColor": "string (used when alternatingRowColors is not set)",
222
+ "evenRowColor": "string (legacy)",
223
+ "oddRowColor": "string (legacy)",
224
+ "alternatingRowOpacity": "number (legacy)"
220
225
  }
221
226
  ```
222
227
 
@@ -243,7 +248,7 @@ This document provides schemas and examples for the `loadFromJSON` method used i
243
248
  "backgroundCornerRadius": 15,
244
249
  "backgroundOpacity": 1.0,
245
250
  "showBackground": true,
246
- "alternatingRowColors": ["#F0F0F0", "#FFFFFF"]
251
+ "alternatingRowColors": ["#EEEEEE", "#FFFFFF"]
247
252
  }
248
253
  ```
249
254
 
@@ -295,23 +300,149 @@ This document provides schemas and examples for the `loadFromJSON` method used i
295
300
 
296
301
  ## 8. `omdShapes`
297
302
 
298
- `omdShapes` represents geometric shapes such as circles, rectangles, and polygons.
303
+ `omdShapes` is a module containing several individual geometric shape classes. Each is used by `omdCoordinatePlane`'s `shapeSet` array (via the `omdType` field) and can also be instantiated independently.
299
304
 
300
- ### Schema
305
+ ---
306
+
307
+ ### 8a. `omdRightTriangle`
308
+
309
+ #### Schema
301
310
  ```json
302
311
  {
303
- "type": "string",
304
- "dimensions": "object",
305
- "color": "string"
312
+ "horizontalLeg": "number (default: 5)",
313
+ "verticalLeg": "number (default: 10)",
314
+ "angleA": "number (optional) - angle in degrees; used with hypotenuse to compute legs",
315
+ "hypotenuse": "number (optional)",
316
+ "unitScale": "number (default: 10) - pixels per unit",
317
+ "showLabels": "boolean (default: false)"
306
318
  }
307
319
  ```
308
320
 
309
- ### Example
321
+ #### Example
310
322
  ```json
311
323
  {
312
- "type": "circle",
313
- "dimensions": { "radius": 10 },
314
- "color": "#FF0000"
324
+ "omdType": "rightTriangle",
325
+ "horizontalLeg": 3,
326
+ "verticalLeg": 4,
327
+ "unitScale": 20,
328
+ "showLabels": true
329
+ }
330
+ ```
331
+
332
+ ---
333
+
334
+ ### 8b. `omdIsoscelesTriangle`
335
+
336
+ #### Schema
337
+ ```json
338
+ {
339
+ "base": "number (default: 5)",
340
+ "height": "number (default: 10)",
341
+ "unitScale": "number (default: 10)",
342
+ "showLabels": "boolean (default: false)"
343
+ }
344
+ ```
345
+
346
+ #### Example
347
+ ```json
348
+ {
349
+ "omdType": "isoscelesTriangle",
350
+ "base": 6,
351
+ "height": 8,
352
+ "unitScale": 15
353
+ }
354
+ ```
355
+
356
+ ---
357
+
358
+ ### 8c. `omdRectangle`
359
+
360
+ #### Schema
361
+ ```json
362
+ {
363
+ "width": "number (default: 10)",
364
+ "height": "number (default: 10)",
365
+ "unitScale": "number (default: 10)",
366
+ "showLabels": "boolean (default: false)"
367
+ }
368
+ ```
369
+
370
+ #### Example
371
+ ```json
372
+ {
373
+ "omdType": "rectangle",
374
+ "width": 5,
375
+ "height": 3,
376
+ "unitScale": 20,
377
+ "showLabels": true
378
+ }
379
+ ```
380
+
381
+ ---
382
+
383
+ ### 8d. `omdEllipse`
384
+
385
+ #### Schema
386
+ ```json
387
+ {
388
+ "width": "number (default: 10)",
389
+ "height": "number (default: 5)",
390
+ "unitScale": "number (default: 10)"
391
+ }
392
+ ```
393
+
394
+ #### Example
395
+ ```json
396
+ {
397
+ "omdType": "ellipse",
398
+ "width": 8,
399
+ "height": 4,
400
+ "unitScale": 12
401
+ }
402
+ ```
403
+
404
+ ---
405
+
406
+ ### 8e. `omdCircle`
407
+
408
+ #### Schema
409
+ ```json
410
+ {
411
+ "radius": "number (default: 5)",
412
+ "unitScale": "number (default: 10)"
413
+ }
414
+ ```
415
+
416
+ #### Example
417
+ ```json
418
+ {
419
+ "omdType": "circle",
420
+ "radius": 4,
421
+ "unitScale": 15
422
+ }
423
+ ```
424
+
425
+ ---
426
+
427
+ ### 8f. `omdRegularPolygon`
428
+
429
+ #### Schema
430
+ ```json
431
+ {
432
+ "radius": "number (default: 5)",
433
+ "numberOfSides": "number (default: 5)",
434
+ "unitScale": "number (default: 10)",
435
+ "showLabels": "boolean (default: false)"
436
+ }
437
+ ```
438
+
439
+ #### Example
440
+ ```json
441
+ {
442
+ "omdType": "regularPolygon",
443
+ "radius": 5,
444
+ "numberOfSides": 6,
445
+ "unitScale": 18
315
446
  }
316
447
  ```
317
448
 
@@ -346,14 +477,16 @@ This document provides schemas and examples for the `loadFromJSON` method used i
346
477
  ### Schema
347
478
  ```json
348
479
  {
349
- "valueA": "number",
350
- "valueB": "number",
351
- "renderType": "string",
352
- "size": "string"
480
+ "valueA": "number - filled/first portion (alias: numerator)",
481
+ "valueB": "number - unfilled/second portion",
482
+ "numerator": "number (optional alias for valueA)",
483
+ "denominator": "number (optional) - when provided with numerator, sets valueA=numerator and valueB=denominator-numerator",
484
+ "renderType": "string - 'pie' | 'dots' | 'dot' | 'tile' | 'bar' (default: 'pie')",
485
+ "size": "string - 'small' | 'medium' | 'large' (default: 'large')"
353
486
  }
354
487
  ```
355
488
 
356
- ### Example
489
+ ### Example (basic)
357
490
  ```json
358
491
  {
359
492
  "valueA": 3,
@@ -363,6 +496,16 @@ This document provides schemas and examples for the `loadFromJSON` method used i
363
496
  }
364
497
  ```
365
498
 
499
+ ### Example (fraction form)
500
+ ```json
501
+ {
502
+ "numerator": 2,
503
+ "denominator": 5,
504
+ "renderType": "dots",
505
+ "size": "medium"
506
+ }
507
+ ```
508
+
366
509
  ---
367
510
 
368
511
  ## 11. `omdProblem`
@@ -465,6 +608,7 @@ This document provides schemas and examples for the `loadFromJSON` method used i
465
608
  ```json
466
609
  {
467
610
  "title": "string (optional)",
611
+ "label": "string (optional) - secondary label text",
468
612
  "min": "number (required)",
469
613
  "max": "number (required)",
470
614
  "increment": "number (optional, default: 1)",
@@ -618,10 +762,14 @@ This document provides schemas and examples for the `loadFromJSON` method used i
618
762
  ### Schema
619
763
  ```json
620
764
  {
621
- "graphEquations": ["array"],
622
- "lineSegments": ["array"],
623
- "dotValues": ["array"],
624
- "shapeSet": ["array"],
765
+ "graphEquations": [
766
+ "array of objects: { equation, color, strokeWidth, domain: { min, max }, label, labelAtX, labelPosition: 'above'|'below'|'left'|'right' }"
767
+ ],
768
+ "lineSegments": [
769
+ "array of objects: { point1: [x, y], point2: [x, y], color, strokeWidth }"
770
+ ],
771
+ "dotValues": ["array of [x, y] or [x, y, color] tuples"],
772
+ "shapeSet": ["array of shape objects with omdType (see omdShapes)"],
625
773
  "xMin": "number",
626
774
  "xMax": "number",
627
775
  "yMin": "number",
@@ -629,7 +777,7 @@ This document provides schemas and examples for the `loadFromJSON` method used i
629
777
  "xLabel": "string",
630
778
  "yLabel": "string",
631
779
  "axisLabelOffsetPx": "number",
632
- "size": "string",
780
+ "size": "string - 'small' | 'medium' | 'large'",
633
781
  "tickInterval": "number",
634
782
  "forceAllTickLabels": "boolean",
635
783
  "tickLabelOffsetPx": "number",
@@ -645,14 +793,22 @@ This document provides schemas and examples for the `loadFromJSON` method used i
645
793
  ```json
646
794
  {
647
795
  "graphEquations": [
648
- { "equation": "y = x^2", "color": "blue", "strokeWidth": 2, "domain": { "min": -5, "max": 5 } }
796
+ {
797
+ "equation": "y = x^2",
798
+ "color": "blue",
799
+ "strokeWidth": 2,
800
+ "domain": { "min": -5, "max": 5 },
801
+ "label": "f(x) = x²",
802
+ "labelAtX": 2,
803
+ "labelPosition": "above"
804
+ }
649
805
  ],
650
806
  "lineSegments": [
651
807
  { "point1": [0, 0], "point2": [1, 1], "color": "red", "strokeWidth": 2 }
652
808
  ],
653
809
  "dotValues": [[0, 0, "green"], [1, 1, "blue"]],
654
810
  "shapeSet": [
655
- { "omdType": "circle", "radius": 5, "color": "yellow" }
811
+ { "omdType": "circle", "radius": 1, "unitScale": 15 }
656
812
  ],
657
813
  "xMin": -5,
658
814
  "xMax": 5,
@@ -682,7 +838,8 @@ This document provides schemas and examples for the `loadFromJSON` method used i
682
838
  ### Preferred schema (string form)
683
839
  ```json
684
840
  {
685
- "equation": "sin(x) + 2 = 3"
841
+ "equation": "sin(x) + 2 = 3",
842
+ "fontSize": "number (optional) - font size for the rendered equation"
686
843
  }
687
844
  ```
688
845
 
@@ -700,5 +857,48 @@ This document provides schemas and examples for the `loadFromJSON` method used i
700
857
  { "equation": "sin(x) + 2 = 3" }
701
858
  ```
702
859
  ```json
703
- { "equation": "(x^2 + 3x - 4)/(2x) = 5" }
860
+ { "equation": "(x^2 + 3x - 4)/(2x) = 5", "fontSize": 20 }
861
+ ```
862
+
863
+ ---
864
+
865
+ ## 21. `omdBalanceHanger`
866
+
867
+ `omdBalanceHanger` is a visual balance/scale with values stacked on each arm. Variable values (strings) render as ellipse pills; numeric values render as rounded rectangles. The `tilt` property visually tips the beam left or right.
868
+
869
+ ### Schema
870
+ ```json
871
+ {
872
+ "leftValues": ["array - strings or numbers to stack on the left arm (default: [])"],
873
+ "rightValues": ["array - strings or numbers to stack on the right arm (default: [])"],
874
+ "tilt": "string - 'none' | 'left' | 'right' (default: 'none')",
875
+ "fontFamily": "string (default: 'Albert Sans')",
876
+ "fontSize": "number (default: 18)",
877
+ "backgroundColor": "string (default: lightGray)",
878
+ "backgroundCornerRadius": "number (default: 5)",
879
+ "backgroundOpacity": "number (default: 1.0)",
880
+ "showBackground": "boolean (default: true)"
881
+ }
882
+ ```
883
+
884
+ ### Example
885
+ ```json
886
+ {
887
+ "leftValues": ["x", "x", "3"],
888
+ "rightValues": ["9"],
889
+ "tilt": "none",
890
+ "fontFamily": "Albert Sans",
891
+ "fontSize": 18
892
+ }
893
+ ```
894
+
895
+ ### Example (tilted)
896
+ ```json
897
+ {
898
+ "leftValues": [5, 3],
899
+ "rightValues": [4],
900
+ "tilt": "left",
901
+ "backgroundColor": "#E0F0FF",
902
+ "backgroundCornerRadius": 8
903
+ }
704
904
  ```
@@ -12,6 +12,18 @@ import {
12
12
  export class omdCoordinatePlane extends jsvgGroup {
13
13
  constructor() {
14
14
  super();
15
+
16
+ // Replace the default <g> element with an <svg> element so that
17
+ // overflow:hidden is respected and getBBox() returns the correct
18
+ // clipped dimensions (fixing the oversized selection box bug).
19
+ const svgNS = "http://www.w3.org/2000/svg";
20
+ const svgEl = document.createElementNS(svgNS, "svg");
21
+ if (this.svgObject && this.svgObject.parentNode) {
22
+ this.svgObject.parentNode.replaceChild(svgEl, this.svgObject);
23
+ }
24
+ this.svgObject = svgEl;
25
+ this.svgObject.setAttribute("overflow", "hidden");
26
+
15
27
  this.graphEquations = [];
16
28
  this.lineSegments = [];
17
29
  this.dotValues = [];