hyperbook 0.95.1 → 0.96.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.
@@ -246,6 +246,42 @@ hyperbook.python = (function () {
246
246
  output.appendChild(line);
247
247
  };
248
248
 
249
+ // ─── Python Friendly Error Messages ────────────────────────────────────────
250
+ // Loaded from python-friendly-error-messages.js (built from npm package)
251
+
252
+ if (window.PythonFriendlyErrorMessages) {
253
+ const { loadCopydeckFor, registerAdapter, pyodideAdapter } = window.PythonFriendlyErrorMessages;
254
+ loadCopydeckFor("en");
255
+ registerAdapter("pyodide", pyodideAdapter);
256
+ }
257
+
258
+ const appendFriendlyError = (output, errorString, code) => {
259
+ if (!output) return;
260
+ let result = null;
261
+ if (window.PythonFriendlyErrorMessages) {
262
+ try {
263
+ result = window.PythonFriendlyErrorMessages.friendlyExplain({
264
+ error: String(errorString),
265
+ code,
266
+ runtime: "pyodide",
267
+ });
268
+ } catch {}
269
+ }
270
+ if (result?.html) {
271
+ const div = document.createElement("div");
272
+ div.classList.add("pfem");
273
+ div.innerHTML = result.html;
274
+ output.appendChild(div);
275
+ } else {
276
+ const span = document.createElement("span");
277
+ span.classList.add("error-line");
278
+ span.textContent = String(errorString);
279
+ output.appendChild(span);
280
+ }
281
+ };
282
+
283
+ // ───────────────────────────────────────────────────────────────────────────
284
+
249
285
  const appendOutput = (output, message, isError = false, id = null) => {
250
286
  if (!output || message === undefined || message === null) return;
251
287
  if (isError) {
@@ -311,10 +347,26 @@ hyperbook.python = (function () {
311
347
  const createTurtleJsFFI = (id) => {
312
348
  const DEFAULT_LINE_WIDTH = 1;
313
349
  const DEFAULT_FONT_SIZE = 8;
314
- const TK_TEXT_X_OFFSET = -1;
315
- const DEFAULT_TURTLE_SCREEN_WIDTH = 400;
316
- const TURTLE_SIZE = 20;
317
-
350
+ const DEFAULT_SHAPE = "classic";
351
+
352
+ // Shape polygons in canvas-local coords (+x = forward, +y = down).
353
+ // Derived from CPython/RPi turtle shapes by applying a 90° CCW screen rotation:
354
+ // (sx, sy) → (sy, -sx).
355
+ const TURTLE_SHAPES = {
356
+ classic: [[0, 0], [-9, 5], [-7, 0], [-9, -5]],
357
+ arrow: [[0, 10], [0, -10], [10, 0]],
358
+ triangle: [[-5.77, -10], [11.55, 0], [-5.77, 10]],
359
+ square: [[-10, -10], [10, -10], [10, 10], [-10, 10]],
360
+ circle: null, // rendered as arc, not polygon
361
+ turtle: [
362
+ [16, 0], [14, 2], [10, 1], [7, 4], [9, 7], [8, 9],
363
+ [5, 6], [1, 7], [-3, 5], [-6, 8], [-8, 6], [-5, 4],
364
+ [-7, 0], [-5, -4], [-8, -6], [-6, -8], [-3, -5],
365
+ [1, -7], [5, -6], [8, -9], [9, -7], [7, -4], [10, -1], [14, -2],
366
+ ],
367
+ };
368
+
369
+ // ---- Shared canvas/screen state ----
318
370
  let pyodide = null;
319
371
  let canvas = null;
320
372
  let context = null;
@@ -322,39 +374,23 @@ hyperbook.python = (function () {
322
374
  let cssHeight = 0;
323
375
  let dpr = 1;
324
376
  let active = false;
325
-
326
- let x = 0;
327
- let y = 0;
328
- let heading = 0;
329
- let renderedX = 0;
330
- let renderedY = 0;
331
- let renderedHeading = 0;
332
- let penDown = true;
333
- let turtleVisible = true;
334
- let renderedTurtleVisible = true;
335
- let strokeColor = "#000000";
336
- let fillColor = "#000000";
337
377
  let backgroundColor = "#ffffff";
338
378
  let backgroundImage = null;
339
- let fontSize = DEFAULT_FONT_SIZE;
340
- let currentFontFamily = "Arial";
341
- let currentFontStyle = "normal";
342
379
  let colorMode = 1.0;
343
380
  let delayMs = 80;
344
381
  let turtleSpeed = 3;
345
- let screenWidth = null;
346
- let screenHeight = null;
347
- let filling = false;
348
- let fillPath = null;
349
- let currentPath = null;
350
- /** @type {Array<any>} */
351
- let paths = [];
382
+ let screenWidth = 640;
383
+ let screenHeight = 480;
352
384
  let operationQueue = [];
353
385
  let queueGeneration = 0;
354
386
  let queueRunning = false;
355
387
  const textMeasureCanvas = document.createElement("canvas");
356
388
  const textMeasureContext = textMeasureCanvas.getContext("2d");
357
389
 
390
+ // Registry of all active turtle pens (rendering state objects)
391
+ const allPens = [];
392
+
393
+ // ---- Shared helper functions ----
358
394
  const normalizeAngle = (angle) => {
359
395
  const value = Number(angle) || 0;
360
396
  return ((value % 360) + 360) % 360;
@@ -501,6 +537,37 @@ hyperbook.python = (function () {
501
537
  pxApprox * 0.2,
502
538
  };
503
539
  };
540
+ const toColorString = (value) => {
541
+ if (typeof value === "string") {
542
+ return value;
543
+ }
544
+ if (value && typeof value.toJs === "function") {
545
+ return toColorString(value.toJs({ pyproxies: [] }));
546
+ }
547
+ if (Array.isArray(value) || (value && typeof value === "object" && "length" in value)) {
548
+ const parts = Array.from(value).slice(0, 3).map((part) => Number(part));
549
+ if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) {
550
+ throw new Error(`bad color sequence: ${String(value)}`);
551
+ }
552
+ if (colorMode === 1.0) {
553
+ if (parts.some((part) => part < 0 || part > 1)) {
554
+ throw new Error(`bad color sequence: ${String(value)}`);
555
+ }
556
+ const [r, g, b] = parts.map((part) => Math.round(part * 255));
557
+ return `rgb(${r}, ${g}, ${b})`;
558
+ }
559
+ if (colorMode === 255) {
560
+ if (parts.some((part) => part < 0 || part > 255)) {
561
+ throw new Error(`bad color sequence: ${String(value)}`);
562
+ }
563
+ const [r, g, b] = parts.map((part) => Math.round(part));
564
+ return `rgb(${r}, ${g}, ${b})`;
565
+ }
566
+ throw new Error(`bad color sequence: ${String(value)}`);
567
+ }
568
+ return "#000000";
569
+ };
570
+
504
571
  const sleep = (ms) => new Promise((resolve) => window.setTimeout(resolve, ms));
505
572
  const clearQueue = () => {
506
573
  queueGeneration += 1;
@@ -571,41 +638,6 @@ hyperbook.python = (function () {
571
638
  return true;
572
639
  };
573
640
 
574
- const makePath = (overrides = {}) => ({
575
- down: penDown,
576
- stroke: strokeColor,
577
- lineWidth: DEFAULT_LINE_WIDTH,
578
- fontsize: fontSize,
579
- fontfamily: currentFontFamily,
580
- fontstyle: currentFontStyle,
581
- fill: false,
582
- fillstyle: fillColor,
583
- points: [{ x, y }],
584
- ...overrides,
585
- });
586
-
587
- const beginCurrentPath = () => {
588
- currentPath = makePath();
589
- paths.push(currentPath);
590
- return currentPath;
591
- };
592
-
593
- const commitStyleToNewPath = () => {
594
- currentPath = makePath({
595
- down: penDown,
596
- lineWidth: currentPath?.lineWidth ?? DEFAULT_LINE_WIDTH,
597
- });
598
- paths.push(currentPath);
599
- return currentPath;
600
- };
601
-
602
- const ensurePath = () => {
603
- if (!currentPath) {
604
- beginCurrentPath();
605
- }
606
- return currentPath;
607
- };
608
-
609
641
  const drawBackground = () => {
610
642
  context.save();
611
643
  context.setTransform(dpr, 0, 0, dpr, 0, 0);
@@ -649,22 +681,11 @@ hyperbook.python = (function () {
649
681
  const text = String(point.text);
650
682
  const metrics = measureTurtleText(text, family, size, style);
651
683
  const align = normalizeTextAlign(point.align);
652
- const left = metrics.left || 0;
653
- const right = metrics.right || metrics.width || 0;
654
- const descent = metrics.descent || 0;
655
- const anchorX = px + TK_TEXT_X_OFFSET;
656
- let drawX = anchorX + left;
657
- if (align === "center") {
658
- drawX = anchorX + (left - right) / 2;
659
- } else if (align === "right") {
660
- drawX = anchorX - right;
661
- }
662
- const drawY = py - descent;
663
684
  context.font = metrics.font;
664
685
  context.fillStyle = point.color || path.stroke;
665
- context.textAlign = "left";
686
+ context.textAlign = align;
666
687
  context.textBaseline = "alphabetic";
667
- context.fillText(text, drawX, drawY);
688
+ context.fillText(text, px, py);
668
689
  continue;
669
690
  }
670
691
  if (point.dotRadius) {
@@ -691,22 +712,31 @@ hyperbook.python = (function () {
691
712
  }
692
713
  };
693
714
 
694
- const drawTurtle = () => {
695
- if (!renderedTurtleVisible) return;
715
+ const drawTurtleShape = (pen) => {
716
+ if (!pen.renderedTurtleVisible) return;
696
717
  context.save();
697
- context.translate(toCanvasX(renderedX), toCanvasY(renderedY));
698
- context.rotate(-toRadians(renderedHeading));
699
- context.beginPath();
700
- context.moveTo(TURTLE_SIZE / 2, 0);
701
- context.lineTo(-TURTLE_SIZE / 2, TURTLE_SIZE / 3);
702
- context.lineTo(-TURTLE_SIZE / 4, 0);
703
- context.lineTo(-TURTLE_SIZE / 2, -TURTLE_SIZE / 3);
704
- context.closePath();
705
- context.fillStyle = "#2f9e44";
706
- context.strokeStyle = "#1b5e20";
718
+ context.translate(toCanvasX(pen.renderedX), toCanvasY(pen.renderedY));
719
+ context.rotate(-toRadians(pen.renderedHeading));
720
+ context.fillStyle = pen.shapeColor;
721
+ context.strokeStyle = pen.shapeColor;
707
722
  context.lineWidth = 1;
708
- context.fill();
709
- context.stroke();
723
+ const shapeName = pen.shapeName || DEFAULT_SHAPE;
724
+ if (shapeName === "circle") {
725
+ context.beginPath();
726
+ context.arc(0, 0, 10, 0, 2 * Math.PI);
727
+ context.fill();
728
+ context.stroke();
729
+ } else {
730
+ const points = TURTLE_SHAPES[shapeName] || TURTLE_SHAPES[DEFAULT_SHAPE];
731
+ context.beginPath();
732
+ context.moveTo(points[0][0], points[0][1]);
733
+ for (let i = 1; i < points.length; i++) {
734
+ context.lineTo(points[i][0], points[i][1]);
735
+ }
736
+ context.closePath();
737
+ context.fill();
738
+ context.stroke();
739
+ }
710
740
  context.restore();
711
741
  };
712
742
 
@@ -714,205 +744,491 @@ hyperbook.python = (function () {
714
744
  if (!active) return;
715
745
  if (!setupCanvasResolution()) return;
716
746
  drawBackground();
717
- paths.forEach(drawPathSegment);
718
- drawTurtle();
719
- };
747
+ for (const pen of allPens) {
748
+ pen.paths.forEach(drawPathSegment);
749
+ }
750
+ for (const pen of allPens) {
751
+ drawTurtleShape(pen);
752
+ }
753
+ };
754
+
755
+ // ---- Per-turtle pen factory ----
756
+ const createTurtlePen = () => {
757
+ let x = 0;
758
+ let y = 0;
759
+ let heading = 0;
760
+ let penDown = true;
761
+ let turtleVisible = true;
762
+ let strokeColor = "#000000";
763
+ let fillColor = "#000000";
764
+ let fontSize = DEFAULT_FONT_SIZE;
765
+ let currentFontFamily = "Arial";
766
+ let currentFontStyle = "normal";
767
+ let filling = false;
768
+ let fillPath = null;
769
+ let currentPath = null;
770
+
771
+ // Mutable rendering state exposed to the shared draw() function
772
+ const pen = {
773
+ paths: [],
774
+ renderedX: 0,
775
+ renderedY: 0,
776
+ renderedHeading: 0,
777
+ renderedTurtleVisible: true,
778
+ shapeColor: "#000000",
779
+ shapeName: DEFAULT_SHAPE,
780
+ };
720
781
 
721
- const resetState = () => {
722
- paths = [];
723
- currentPath = null;
724
- fillPath = null;
725
- x = 0;
726
- y = 0;
727
- heading = 0;
728
- renderedX = 0;
729
- renderedY = 0;
730
- renderedHeading = 0;
731
- penDown = true;
732
- turtleVisible = true;
733
- renderedTurtleVisible = true;
734
- strokeColor = "#000000";
735
- fillColor = "#000000";
736
- backgroundColor = "#ffffff";
737
- backgroundImage = null;
738
- fontSize = DEFAULT_FONT_SIZE;
739
- currentFontFamily = "Arial";
740
- currentFontStyle = "normal";
741
- colorMode = 1.0;
742
- screenWidth = null;
743
- screenHeight = null;
744
- filling = false;
745
- active = true;
746
- clearQueue();
747
- beginCurrentPath();
748
- draw();
749
- };
782
+ const makePath = (overrides = {}) => ({
783
+ down: penDown,
784
+ stroke: strokeColor,
785
+ lineWidth: DEFAULT_LINE_WIDTH,
786
+ fontsize: fontSize,
787
+ fontfamily: currentFontFamily,
788
+ fontstyle: currentFontStyle,
789
+ fill: false,
790
+ fillstyle: fillColor,
791
+ points: [{ x, y }],
792
+ ...overrides,
793
+ });
750
794
 
751
- const deactivate = () => {
752
- active = false;
753
- clearQueue();
754
- paths = [];
755
- currentPath = null;
756
- fillPath = null;
757
- turtleVisible = false;
758
- renderedTurtleVisible = false;
759
- };
795
+ const beginCurrentPath = () => {
796
+ currentPath = makePath();
797
+ pen.paths.push(currentPath);
798
+ return currentPath;
799
+ };
760
800
 
761
- const bindCanvas = (nextCanvas) => {
762
- canvas = nextCanvas || getCanvas(id);
763
- context = canvas?.getContext?.("2d") || null;
764
- if (canvas) {
765
- canvas.tabIndex = 0;
766
- }
767
- if (!currentPath) {
768
- beginCurrentPath();
769
- }
770
- draw();
771
- };
801
+ const commitStyleToNewPath = () => {
802
+ currentPath = makePath({
803
+ down: penDown,
804
+ lineWidth: currentPath?.lineWidth ?? DEFAULT_LINE_WIDTH,
805
+ });
806
+ pen.paths.push(currentPath);
807
+ return currentPath;
808
+ };
772
809
 
773
- const toColorString = (value) => {
774
- if (typeof value === "string") {
775
- return value;
776
- }
777
- if (value && typeof value.toJs === "function") {
778
- return toColorString(value.toJs({ pyproxies: [] }));
779
- }
780
- if (Array.isArray(value) || (value && typeof value === "object" && "length" in value)) {
781
- const parts = Array.from(value).slice(0, 3).map((part) => Number(part));
782
- if (parts.length !== 3 || parts.some((part) => !Number.isFinite(part))) {
783
- throw new Error(`bad color sequence: ${String(value)}`);
810
+ const ensurePath = () => {
811
+ if (!currentPath) {
812
+ beginCurrentPath();
784
813
  }
785
- if (colorMode === 1.0) {
786
- if (parts.some((part) => part < 0 || part > 1)) {
787
- throw new Error(`bad color sequence: ${String(value)}`);
814
+ return currentPath;
815
+ };
816
+
817
+ const getPenState = () => ({
818
+ pendown: penDown,
819
+ pencolor: strokeColor,
820
+ fillcolor: fillColor,
821
+ pensize: currentPath?.lineWidth ?? DEFAULT_LINE_WIDTH,
822
+ speed: turtleSpeed,
823
+ shown: turtleVisible,
824
+ });
825
+
826
+ beginCurrentPath();
827
+
828
+ const forward = (distance) => {
829
+ if (!ensureContext()) return;
830
+ const path = ensurePath();
831
+ const fill = filling ? fillPath : null;
832
+ const length = Number(distance) || 0;
833
+ const nextX = x + length * Math.cos(toRadians(heading));
834
+ const nextY = y + length * Math.sin(toRadians(heading));
835
+ x = nextX;
836
+ y = nextY;
837
+ const point = { x, y, move: !penDown };
838
+ enqueueOperation(() => {
839
+ pen.renderedX = point.x;
840
+ pen.renderedY = point.y;
841
+ path.points.push(point);
842
+ if (fill) {
843
+ fill.points.push({ x, y });
788
844
  }
789
- const [r, g, b] = parts.map((part) => Math.round(part * 255));
790
- return `rgb(${r}, ${g}, ${b})`;
845
+ draw();
846
+ });
847
+ };
848
+
849
+ const backward = (distance) => forward(-Number(distance || 0));
850
+ const right = (angle) => {
851
+ heading = normalizeAngle(heading - Number(angle || 0));
852
+ const nextHeading = heading;
853
+ enqueueOperation(() => {
854
+ pen.renderedHeading = nextHeading;
855
+ draw();
856
+ });
857
+ };
858
+ const left = (angle) => {
859
+ heading = normalizeAngle(heading + Number(angle || 0));
860
+ const nextHeading = heading;
861
+ enqueueOperation(() => {
862
+ pen.renderedHeading = nextHeading;
863
+ draw();
864
+ });
865
+ };
866
+ const penup = () => {
867
+ penDown = false;
868
+ commitStyleToNewPath();
869
+ draw();
870
+ };
871
+ const pendownFn = () => {
872
+ penDown = true;
873
+ commitStyleToNewPath();
874
+ draw();
875
+ };
876
+ const goto_ = (a, b) => {
877
+ if (!ensureContext()) return;
878
+ let nextX = Number(a);
879
+ let nextY = Number(b);
880
+ if (b === undefined && (Array.isArray(a) || (a && typeof a === "object"))) {
881
+ const source =
882
+ a && typeof a.toJs === "function" ? a.toJs({ pyproxies: [] }) : a;
883
+ nextX = Number(source?.[0] ?? source?.x ?? 0);
884
+ nextY = Number(source?.[1] ?? source?.y ?? 0);
791
885
  }
792
- if (colorMode === 255) {
793
- if (parts.some((part) => part < 0 || part > 255)) {
794
- throw new Error(`bad color sequence: ${String(value)}`);
886
+ x = Number.isFinite(nextX) ? nextX : 0;
887
+ y = Number.isFinite(nextY) ? nextY : 0;
888
+ const path = ensurePath();
889
+ const fill = filling ? fillPath : null;
890
+ const point = { x, y, move: !penDown };
891
+ enqueueOperation(() => {
892
+ pen.renderedX = point.x;
893
+ pen.renderedY = point.y;
894
+ path.points.push(point);
895
+ if (fill) {
896
+ fill.points.push({ x, y });
795
897
  }
796
- const [r, g, b] = parts.map((part) => Math.round(part));
797
- return `rgb(${r}, ${g}, ${b})`;
898
+ draw();
899
+ });
900
+ };
901
+ const setx = (value) => goto_(Number(value), y);
902
+ const sety = (value) => goto_(x, Number(value));
903
+ const position = () => [x, y];
904
+ const xcor = () => x;
905
+ const ycor = () => y;
906
+ const heading_ = () => heading;
907
+ const setheading = (angle) => {
908
+ heading = normalizeAngle(angle);
909
+ const nextHeading = heading;
910
+ enqueueOperation(() => {
911
+ pen.renderedHeading = nextHeading;
912
+ draw();
913
+ });
914
+ };
915
+ const home = () => {
916
+ goto_(0, 0);
917
+ setheading(0);
918
+ };
919
+ const towards = (tx, ty) => {
920
+ const dx = Number(tx) - x;
921
+ const dy = Number(ty) - y;
922
+ if (!Number.isFinite(dx) || !Number.isFinite(dy)) return 0;
923
+ return normalizeAngle((Math.atan2(dy, dx) * 180) / Math.PI);
924
+ };
925
+ const speed = (value = 0) => {
926
+ const numeric = Number(value);
927
+ turtleSpeed = Number.isFinite(numeric) ? numeric : 0;
928
+ delayMs = turtleSpeed <= 0 ? 0 : Math.max(0, Math.round(300 / turtleSpeed));
929
+ return delayMs;
930
+ };
931
+ const color = (...args) => {
932
+ const stroke = toColorString(args[0]);
933
+ const fill = args.length > 1 ? toColorString(args[1]) : stroke;
934
+ strokeColor = stroke;
935
+ fillColor = fill;
936
+ pen.shapeColor = stroke;
937
+ commitStyleToNewPath();
938
+ if (filling && fillPath) {
939
+ fillPath.fillstyle = fillColor;
798
940
  }
799
- throw new Error(`bad color sequence: ${String(value)}`);
800
- }
801
- return "#000000";
802
- };
803
-
804
- const getPenState = () => ({
805
- pendown: penDown,
806
- pencolor: strokeColor,
807
- fillcolor: fillColor,
808
- pensize: currentPath?.lineWidth ?? DEFAULT_LINE_WIDTH,
809
- speed: turtleSpeed,
810
- shown: turtleVisible,
811
- });
812
-
813
- const forward = (distance) => {
814
- if (!ensureContext()) return;
815
- const path = ensurePath();
816
- const fill = filling ? fillPath : null;
817
- const length = Number(distance) || 0;
818
- const nextX = x + length * Math.cos(toRadians(heading));
819
- const nextY = y + length * Math.sin(toRadians(heading));
820
- x = nextX;
821
- y = nextY;
822
- const point = { x, y, move: !penDown };
823
- enqueueOperation(() => {
824
- renderedX = point.x;
825
- renderedY = point.y;
826
- path.points.push(point);
827
- if (fill) {
828
- fill.points.push({ x, y });
941
+ draw();
942
+ };
943
+ const pencolor = (value) => {
944
+ strokeColor = toColorString(value);
945
+ pen.shapeColor = strokeColor;
946
+ commitStyleToNewPath();
947
+ draw();
948
+ };
949
+ const fillcolor = (value) => {
950
+ fillColor = toColorString(value);
951
+ if (filling && fillPath) {
952
+ fillPath.fillstyle = fillColor;
829
953
  }
954
+ commitStyleToNewPath();
830
955
  draw();
831
- });
832
- };
833
-
834
- const backward = (distance) => forward(-Number(distance || 0));
835
- const right = (angle) => {
836
- heading = normalizeAngle(heading - Number(angle || 0));
837
- const nextHeading = heading;
838
- enqueueOperation(() => {
839
- renderedHeading = nextHeading;
956
+ };
957
+ const pensize = (value) => {
958
+ ensurePath().lineWidth = Math.max(1, Number(value) || DEFAULT_LINE_WIDTH);
959
+ commitStyleToNewPath();
840
960
  draw();
841
- });
842
- };
843
- const left = (angle) => {
844
- heading = normalizeAngle(heading + Number(angle || 0));
845
- const nextHeading = heading;
846
- enqueueOperation(() => {
847
- renderedHeading = nextHeading;
961
+ };
962
+ const width = (value) => pensize(value);
963
+ const begin_fill = () => {
964
+ if (filling) return;
965
+ filling = true;
966
+ fillPath = makePath({
967
+ down: false,
968
+ fill: true,
969
+ stroke: "transparent",
970
+ lineWidth: 1,
971
+ fillstyle: fillColor,
972
+ });
973
+ pen.paths.push(fillPath);
848
974
  draw();
849
- });
850
- };
851
- const penup = () => {
852
- penDown = false;
853
- commitStyleToNewPath();
854
- draw();
855
- };
856
- const pendownFn = () => {
857
- penDown = true;
858
- commitStyleToNewPath();
859
- draw();
860
- };
861
- const goto_ = (a, b) => {
862
- if (!ensureContext()) return;
863
- let nextX = Number(a);
864
- let nextY = Number(b);
865
- if (b === undefined && (Array.isArray(a) || (a && typeof a === "object"))) {
975
+ };
976
+ const end_fill = () => {
977
+ filling = false;
978
+ fillPath = null;
979
+ commitStyleToNewPath();
980
+ draw();
981
+ };
982
+ const dot = (size = 5, colorValue = null) => {
983
+ const segment = makePath({
984
+ down: false,
985
+ fill: false,
986
+ stroke: colorValue ? toColorString(colorValue) : strokeColor,
987
+ fillstyle: colorValue ? toColorString(colorValue) : strokeColor,
988
+ points: [
989
+ {
990
+ x,
991
+ y,
992
+ dotRadius: Math.max(0.5, Number(size) || 5) / 2,
993
+ color: colorValue ? toColorString(colorValue) : strokeColor,
994
+ },
995
+ ],
996
+ });
997
+ enqueueOperation(() => {
998
+ pen.paths.push(segment);
999
+ draw();
1000
+ });
1001
+ };
1002
+ const circle = (radius, steps = 120) => {
1003
+ const numericRadius = Number(radius) || 0;
1004
+ const stepCount = Math.max(8, Number(steps) | 0);
1005
+ const circumference = 2 * Math.PI * Math.abs(numericRadius);
1006
+ const stepLength = circumference / stepCount;
1007
+ const stepTurn = (360 / stepCount) * (numericRadius >= 0 ? 1 : -1);
1008
+ for (let index = 0; index < stepCount; index += 1) {
1009
+ forward(stepLength);
1010
+ left(stepTurn);
1011
+ }
1012
+ };
1013
+ const write = (text, move = false, align = "left", font = null) => {
1014
+ let writeText = text;
1015
+ let writeMove = move;
1016
+ let writeAlign = align;
1017
+ let writeFont = font;
1018
+ const applyWriteKwargs = (candidate) => {
1019
+ if (!isWriteKwargsObject(candidate)) return;
1020
+ const kwargs = toPlainObject(candidate);
1021
+ if (!kwargs) return;
1022
+ if (hasOwn(kwargs, "arg")) writeText = kwargs.arg;
1023
+ else if (hasOwn(kwargs, "text")) writeText = kwargs.text;
1024
+ if (hasOwn(kwargs, "move")) writeMove = kwargs.move;
1025
+ if (hasOwn(kwargs, "align")) writeAlign = kwargs.align;
1026
+ if (hasOwn(kwargs, "font")) writeFont = kwargs.font;
1027
+ };
1028
+ applyWriteKwargs(writeText);
1029
+ applyWriteKwargs(writeMove);
1030
+ applyWriteKwargs(writeAlign);
1031
+ applyWriteKwargs(writeFont);
1032
+ if (isWriteKwargsObject(writeFont)) writeFont = null;
1033
+ if (isWriteKwargsObject(writeMove)) writeMove = false;
1034
+ if (isWriteKwargsObject(writeAlign)) writeAlign = "left";
1035
+
1036
+ let family = currentFontFamily;
1037
+ let size = fontSize;
1038
+ let style = currentFontStyle;
1039
+ try {
1040
+ const source = toSequence(writeFont);
1041
+ if (source?.length) {
1042
+ if (source[0]) family = toPlainString(source[0], family);
1043
+ if (source[1] !== undefined && source[1] !== null) {
1044
+ size = toPlainNumber(source[1], size);
1045
+ }
1046
+ if (source[2]) style = toPlainString(source[2], style);
1047
+ }
1048
+ } catch {}
1049
+ const normalizedAlign = normalizeTextAlign(writeAlign);
1050
+ const segment = makePath({
1051
+ down: false,
1052
+ fill: false,
1053
+ stroke: strokeColor,
1054
+ points: [
1055
+ {
1056
+ x,
1057
+ y,
1058
+ text: String(writeText),
1059
+ align: normalizedAlign,
1060
+ fontsize: size,
1061
+ fontfamily: family,
1062
+ fontstyle: style,
1063
+ color: strokeColor,
1064
+ },
1065
+ ],
1066
+ });
1067
+ if (toPlainBoolean(writeMove, false)) {
1068
+ const metrics = measureTurtleText(String(writeText), family, size, style);
1069
+ const textWidth = metrics.width || 0;
1070
+ if (normalizedAlign === "left") {
1071
+ x += textWidth;
1072
+ } else if (normalizedAlign === "center") {
1073
+ x += textWidth / 2;
1074
+ }
1075
+ // right alignment: turtle stays at its position (text ends there)
1076
+ commitStyleToNewPath();
1077
+ }
1078
+ enqueueOperation(() => {
1079
+ pen.paths.push(segment);
1080
+ draw();
1081
+ });
1082
+ };
1083
+ const setfontsize = (value) => {
1084
+ const nextSize = toPlainNumber(value, DEFAULT_FONT_SIZE);
1085
+ fontSize = Math.max(1, Number.isFinite(nextSize) ? nextSize : DEFAULT_FONT_SIZE);
1086
+ commitStyleToNewPath();
1087
+ draw();
1088
+ };
1089
+ const showturtle = () => {
1090
+ turtleVisible = true;
1091
+ enqueueOperation(() => {
1092
+ pen.renderedTurtleVisible = true;
1093
+ draw();
1094
+ });
1095
+ };
1096
+ const hideturtle = () => {
1097
+ turtleVisible = false;
1098
+ enqueueOperation(() => {
1099
+ pen.renderedTurtleVisible = false;
1100
+ draw();
1101
+ });
1102
+ };
1103
+ const isvisible = () => turtleVisible;
1104
+ const shape = (name = undefined) => {
1105
+ if (name === undefined || name === null) {
1106
+ return pen.shapeName;
1107
+ }
1108
+ const nameStr = toPlainString(name, DEFAULT_SHAPE).toLowerCase().trim();
1109
+ if (nameStr in TURTLE_SHAPES) {
1110
+ pen.shapeName = nameStr;
1111
+ }
1112
+ draw();
1113
+ return pen.shapeName;
1114
+ };
1115
+ const penFn = (options = undefined) => {
1116
+ if (options === undefined) {
1117
+ return getPenState();
1118
+ }
866
1119
  const source =
867
- a && typeof a.toJs === "function" ? a.toJs({ pyproxies: [] }) : a;
868
- nextX = Number(source?.[0] ?? source?.x ?? 0);
869
- nextY = Number(source?.[1] ?? source?.y ?? 0);
870
- }
871
- x = Number.isFinite(nextX) ? nextX : 0;
872
- y = Number.isFinite(nextY) ? nextY : 0;
873
- const path = ensurePath();
874
- const fill = filling ? fillPath : null;
875
- const point = { x, y, move: !penDown };
876
- enqueueOperation(() => {
877
- renderedX = point.x;
878
- renderedY = point.y;
879
- path.points.push(point);
880
- if (fill) {
881
- fill.points.push({ x, y });
1120
+ options && typeof options.toJs === "function"
1121
+ ? options.toJs({ pyproxies: [], dict_converter: Object.fromEntries })
1122
+ : options;
1123
+ if (!source || typeof source !== "object") return getPenState();
1124
+ if ("pendown" in source) {
1125
+ penDown = !!source.pendown;
1126
+ }
1127
+ if ("pencolor" in source) {
1128
+ strokeColor = toColorString(source.pencolor);
1129
+ }
1130
+ if ("fillcolor" in source) {
1131
+ fillColor = toColorString(source.fillcolor);
882
1132
  }
1133
+ if ("pensize" in source) {
1134
+ ensurePath().lineWidth = Math.max(1, Number(source.pensize) || DEFAULT_LINE_WIDTH);
1135
+ }
1136
+ if ("shown" in source) {
1137
+ turtleVisible = !!source.shown;
1138
+ pen.renderedTurtleVisible = turtleVisible;
1139
+ }
1140
+ commitStyleToNewPath();
883
1141
  draw();
884
- });
885
- };
886
- const setx = (value) => goto_(Number(value), y);
887
- const sety = (value) => goto_(x, Number(value));
888
- const position = () => [x, y];
889
- const xcor = () => x;
890
- const ycor = () => y;
891
- const heading_ = () => heading;
892
- const setheading = (angle) => {
893
- heading = normalizeAngle(angle);
894
- const nextHeading = heading;
895
- enqueueOperation(() => {
896
- renderedHeading = nextHeading;
1142
+ return getPenState();
1143
+ };
1144
+ const clear = () => {
1145
+ pen.paths = [];
1146
+ currentPath = null;
1147
+ fillPath = null;
1148
+ beginCurrentPath();
897
1149
  draw();
898
- });
899
- };
900
- const home = () => {
901
- goto_(0, 0);
902
- setheading(0);
903
- };
904
- const towards = (tx, ty) => {
905
- const dx = Number(tx) - x;
906
- const dy = Number(ty) - y;
907
- if (!Number.isFinite(dx) || !Number.isFinite(dy)) return 0;
908
- return normalizeAngle((Math.atan2(dy, dx) * 180) / Math.PI);
909
- };
910
- const speed = (value = 0) => {
911
- const numeric = Number(value);
912
- turtleSpeed = Number.isFinite(numeric) ? numeric : 0;
913
- delayMs = turtleSpeed <= 0 ? 0 : Math.max(0, Math.round(300 / turtleSpeed));
914
- return delayMs;
1150
+ };
1151
+ const resetPen = () => {
1152
+ pen.paths = [];
1153
+ currentPath = null;
1154
+ fillPath = null;
1155
+ x = 0;
1156
+ y = 0;
1157
+ heading = 0;
1158
+ pen.renderedX = 0;
1159
+ pen.renderedY = 0;
1160
+ pen.renderedHeading = 0;
1161
+ penDown = true;
1162
+ turtleVisible = true;
1163
+ pen.renderedTurtleVisible = true;
1164
+ strokeColor = "#000000";
1165
+ fillColor = "#000000";
1166
+ pen.shapeColor = "#000000";
1167
+ pen.shapeName = DEFAULT_SHAPE;
1168
+ fontSize = DEFAULT_FONT_SIZE;
1169
+ currentFontFamily = "Arial";
1170
+ currentFontStyle = "normal";
1171
+ filling = false;
1172
+ beginCurrentPath();
1173
+ };
1174
+
1175
+ const api = {
1176
+ forward,
1177
+ fd: forward,
1178
+ backward,
1179
+ bk: backward,
1180
+ back: backward,
1181
+ right,
1182
+ rt: right,
1183
+ left,
1184
+ lt: left,
1185
+ goto: (...args) => goto_(...args),
1186
+ setpos: (...args) => goto_(...args),
1187
+ setposition: (...args) => goto_(...args),
1188
+ setx,
1189
+ sety,
1190
+ setheading,
1191
+ seth: setheading,
1192
+ home,
1193
+ circle,
1194
+ dot,
1195
+ position,
1196
+ pos: position,
1197
+ xcor,
1198
+ ycor,
1199
+ heading: heading_,
1200
+ towards,
1201
+ pendown: pendownFn,
1202
+ pd: pendownFn,
1203
+ down: pendownFn,
1204
+ penup,
1205
+ pu: penup,
1206
+ up: penup,
1207
+ pensize,
1208
+ width,
1209
+ pen: penFn,
1210
+ write,
1211
+ setfontsize,
1212
+ color,
1213
+ pencolor,
1214
+ fillcolor,
1215
+ begin_fill,
1216
+ end_fill,
1217
+ speed,
1218
+ showturtle,
1219
+ st: showturtle,
1220
+ hideturtle,
1221
+ ht: hideturtle,
1222
+ isvisible,
1223
+ shape,
1224
+ clear,
1225
+ reset: resetPen,
1226
+ };
1227
+
1228
+ return { pen, api, resetPen };
915
1229
  };
1230
+
1231
+ // Screen-level functions
916
1232
  const colormode = (mode = undefined) => {
917
1233
  if (mode === undefined) return colorMode;
918
1234
  const numeric = Number(mode);
@@ -949,164 +1265,6 @@ hyperbook.python = (function () {
949
1265
  }
950
1266
  return [screenWidth || cssWidth || 0, screenHeight || cssHeight || 0];
951
1267
  };
952
- const color = (...args) => {
953
- const stroke = toColorString(args[0]);
954
- const fill = args.length > 1 ? toColorString(args[1]) : stroke;
955
- strokeColor = stroke;
956
- fillColor = fill;
957
- commitStyleToNewPath();
958
- if (filling && fillPath) {
959
- fillPath.fillstyle = fillColor;
960
- }
961
- draw();
962
- };
963
- const pencolor = (value) => {
964
- strokeColor = toColorString(value);
965
- commitStyleToNewPath();
966
- draw();
967
- };
968
- const fillcolor = (value) => {
969
- fillColor = toColorString(value);
970
- if (filling && fillPath) {
971
- fillPath.fillstyle = fillColor;
972
- }
973
- commitStyleToNewPath();
974
- draw();
975
- };
976
- const pensize = (value) => {
977
- ensurePath().lineWidth = Math.max(1, Number(value) || DEFAULT_LINE_WIDTH);
978
- commitStyleToNewPath();
979
- draw();
980
- };
981
- const width = (value) => pensize(value);
982
- const begin_fill = () => {
983
- if (filling) return;
984
- filling = true;
985
- fillPath = makePath({
986
- down: false,
987
- fill: true,
988
- stroke: "transparent",
989
- lineWidth: 1,
990
- fillstyle: fillColor,
991
- });
992
- paths.push(fillPath);
993
- draw();
994
- };
995
- const end_fill = () => {
996
- filling = false;
997
- fillPath = null;
998
- commitStyleToNewPath();
999
- draw();
1000
- };
1001
- const dot = (size = 5, colorValue = null) => {
1002
- const segment = makePath({
1003
- down: false,
1004
- fill: false,
1005
- stroke: colorValue ? toColorString(colorValue) : strokeColor,
1006
- fillstyle: colorValue ? toColorString(colorValue) : strokeColor,
1007
- points: [
1008
- {
1009
- x,
1010
- y,
1011
- dotRadius: Math.max(0.5, Number(size) || 5) / 2,
1012
- color: colorValue ? toColorString(colorValue) : strokeColor,
1013
- },
1014
- ],
1015
- });
1016
- enqueueOperation(() => {
1017
- paths.push(segment);
1018
- draw();
1019
- });
1020
- };
1021
- const circle = (radius, steps = 120) => {
1022
- const numericRadius = Number(radius) || 0;
1023
- const stepCount = Math.max(8, Number(steps) | 0);
1024
- const circumference = 2 * Math.PI * Math.abs(numericRadius);
1025
- const stepLength = circumference / stepCount;
1026
- const stepTurn = (360 / stepCount) * (numericRadius >= 0 ? 1 : -1);
1027
- for (let index = 0; index < stepCount; index += 1) {
1028
- forward(stepLength);
1029
- left(stepTurn);
1030
- }
1031
- };
1032
- const write = (text, move = false, align = "left", font = null) => {
1033
- let writeText = text;
1034
- let writeMove = move;
1035
- let writeAlign = align;
1036
- let writeFont = font;
1037
- const applyWriteKwargs = (candidate) => {
1038
- if (!isWriteKwargsObject(candidate)) return;
1039
- const kwargs = toPlainObject(candidate);
1040
- if (!kwargs) return;
1041
- if (hasOwn(kwargs, "arg")) writeText = kwargs.arg;
1042
- else if (hasOwn(kwargs, "text")) writeText = kwargs.text;
1043
- if (hasOwn(kwargs, "move")) writeMove = kwargs.move;
1044
- if (hasOwn(kwargs, "align")) writeAlign = kwargs.align;
1045
- if (hasOwn(kwargs, "font")) writeFont = kwargs.font;
1046
- };
1047
- applyWriteKwargs(writeText);
1048
- applyWriteKwargs(writeMove);
1049
- applyWriteKwargs(writeAlign);
1050
- applyWriteKwargs(writeFont);
1051
- if (isWriteKwargsObject(writeFont)) writeFont = null;
1052
-
1053
- let family = currentFontFamily;
1054
- let size = fontSize;
1055
- let style = currentFontStyle;
1056
- try {
1057
- const source = toSequence(writeFont);
1058
- if (source?.length) {
1059
- if (source[0]) family = toPlainString(source[0], family);
1060
- if (source[1] !== undefined && source[1] !== null) {
1061
- size = toPlainNumber(source[1], size);
1062
- }
1063
- if (source[2]) style = toPlainString(source[2], style);
1064
- }
1065
- } catch {}
1066
- const normalizedAlign = normalizeTextAlign(writeAlign);
1067
- const segment = makePath({
1068
- down: false,
1069
- fill: false,
1070
- stroke: strokeColor,
1071
- points: [
1072
- {
1073
- x,
1074
- y,
1075
- text: String(writeText),
1076
- align: normalizedAlign,
1077
- fontsize: size,
1078
- fontfamily: family,
1079
- fontstyle: style,
1080
- color: strokeColor,
1081
- },
1082
- ],
1083
- });
1084
- if (toPlainBoolean(writeMove, false)) {
1085
- const metrics = measureTurtleText(String(writeText), family, size, style);
1086
- const left = metrics.left || 0;
1087
- const right = metrics.right || metrics.width || 0;
1088
- const align = normalizeTextAlign(normalizedAlign);
1089
- const textWidth = left + right;
1090
- if (align === "left") {
1091
- x += TK_TEXT_X_OFFSET + textWidth;
1092
- } else if (align === "center") {
1093
- x += TK_TEXT_X_OFFSET + right;
1094
- } else {
1095
- x += TK_TEXT_X_OFFSET;
1096
- }
1097
- commitStyleToNewPath();
1098
- }
1099
- enqueueOperation(() => {
1100
- paths.push(segment);
1101
- draw();
1102
- });
1103
- };
1104
- const setfontsize = (value) => {
1105
- const nextSize = toPlainNumber(value, DEFAULT_FONT_SIZE);
1106
- fontSize = Math.max(1, Number.isFinite(nextSize) ? nextSize : DEFAULT_FONT_SIZE);
1107
- commitStyleToNewPath();
1108
- draw();
1109
- };
1110
1268
  const bgcolor = (value) => {
1111
1269
  backgroundColor = toColorString(value);
1112
1270
  draw();
@@ -1143,60 +1301,55 @@ hyperbook.python = (function () {
1143
1301
  } catch {}
1144
1302
  }
1145
1303
  };
1146
- const clear = () => {
1147
- clearQueue();
1148
- paths = [];
1149
- currentPath = null;
1150
- fillPath = null;
1151
- beginCurrentPath();
1304
+
1305
+ // Create the default turtle pen and register it
1306
+ const defaultPenObj = createTurtlePen();
1307
+ allPens.push(defaultPenObj.pen);
1308
+
1309
+ const bindCanvas = (nextCanvas) => {
1310
+ canvas = nextCanvas || getCanvas(id);
1311
+ context = canvas?.getContext?.("2d") || null;
1312
+ if (canvas) {
1313
+ canvas.tabIndex = 0;
1314
+ }
1152
1315
  draw();
1153
1316
  };
1154
- const reset = () => {
1155
- resetState();
1317
+
1318
+ const deactivate = () => {
1319
+ active = false;
1320
+ clearQueue();
1321
+ for (const pen of allPens) {
1322
+ pen.paths = [];
1323
+ pen.renderedTurtleVisible = false;
1324
+ }
1156
1325
  };
1157
- const showturtle = () => {
1158
- turtleVisible = true;
1159
- enqueueOperation(() => {
1160
- renderedTurtleVisible = true;
1161
- draw();
1162
- });
1326
+
1327
+ const resetState = () => {
1328
+ clearQueue();
1329
+ // Remove all extra turtles, keeping only the default pen
1330
+ allPens.length = 0;
1331
+ allPens.push(defaultPenObj.pen);
1332
+ defaultPenObj.resetPen();
1333
+ backgroundColor = "#ffffff";
1334
+ backgroundImage = null;
1335
+ colorMode = 1.0;
1336
+ screenWidth = 640;
1337
+ screenHeight = 480;
1338
+ active = true;
1339
+ draw();
1163
1340
  };
1164
- const hideturtle = () => {
1165
- turtleVisible = false;
1341
+
1342
+ // Turtle constructor — creates an additional independent turtle on the same canvas.
1343
+ // The pen is registered into allPens via enqueueOperation so it enters the render
1344
+ // loop in queue order, preventing the turtle from appearing at center before prior
1345
+ // drawing operations have completed.
1346
+ const Turtle = function () {
1347
+ const penObj = createTurtlePen();
1166
1348
  enqueueOperation(() => {
1167
- renderedTurtleVisible = false;
1349
+ allPens.push(penObj.pen);
1168
1350
  draw();
1169
1351
  });
1170
- };
1171
- const isvisible = () => turtleVisible;
1172
- const pen = (options = undefined) => {
1173
- if (options === undefined) {
1174
- return getPenState();
1175
- }
1176
- const source =
1177
- options && typeof options.toJs === "function"
1178
- ? options.toJs({ pyproxies: [], dict_converter: Object.fromEntries })
1179
- : options;
1180
- if (!source || typeof source !== "object") return getPenState();
1181
- if ("pendown" in source) {
1182
- penDown = !!source.pendown;
1183
- }
1184
- if ("pencolor" in source) {
1185
- strokeColor = toColorString(source.pencolor);
1186
- }
1187
- if ("fillcolor" in source) {
1188
- fillColor = toColorString(source.fillcolor);
1189
- }
1190
- if ("pensize" in source) {
1191
- ensurePath().lineWidth = Math.max(1, Number(source.pensize) || DEFAULT_LINE_WIDTH);
1192
- }
1193
- if ("shown" in source) {
1194
- turtleVisible = !!source.shown;
1195
- renderedTurtleVisible = turtleVisible;
1196
- }
1197
- commitStyleToNewPath();
1198
- draw();
1199
- return getPenState();
1352
+ return penObj.api;
1200
1353
  };
1201
1354
 
1202
1355
  const api = {
@@ -1207,59 +1360,12 @@ hyperbook.python = (function () {
1207
1360
  __setPyodide: (runtime) => {
1208
1361
  pyodide = runtime;
1209
1362
  },
1210
- forward,
1211
- fd: forward,
1212
- backward,
1213
- bk: backward,
1214
- back: backward,
1215
- right,
1216
- rt: right,
1217
- left,
1218
- lt: left,
1219
- goto: (...args) => goto_(...args),
1220
- setpos: (...args) => goto_(...args),
1221
- setposition: (...args) => goto_(...args),
1222
- setx,
1223
- sety,
1224
- setheading,
1225
- seth: setheading,
1226
- home,
1227
- circle,
1228
- dot,
1229
- position,
1230
- pos: position,
1231
- xcor,
1232
- ycor,
1233
- heading: heading_,
1234
- towards,
1235
- pendown: pendownFn,
1236
- pd: pendownFn,
1237
- down: pendownFn,
1238
- penup,
1239
- pu: penup,
1240
- up: penup,
1241
- pensize,
1242
- width,
1243
- pen,
1244
- write,
1245
- setfontsize,
1246
- color,
1247
- pencolor,
1248
- fillcolor,
1249
- begin_fill,
1250
- end_fill,
1251
- bgcolor,
1252
- bgpic,
1253
- reset,
1254
- clear,
1255
- speed,
1363
+ ...defaultPenObj.api,
1364
+ Turtle,
1256
1365
  colormode,
1257
1366
  screensize,
1258
- showturtle,
1259
- st: showturtle,
1260
- hideturtle,
1261
- ht: hideturtle,
1262
- isvisible,
1367
+ bgcolor,
1368
+ bgpic,
1263
1369
  };
1264
1370
 
1265
1371
  return api;
@@ -1539,6 +1645,156 @@ hyperbook.python = (function () {
1539
1645
  );
1540
1646
  };
1541
1647
 
1648
+ // SVG-based renderer — mirrors buildGraphic but produces SVG markup.
1649
+ // Each stack node has { width, height, pin, svg } where `svg` is a string
1650
+ // drawn with the coordinate origin at the graphic's centre (y-down, same as canvas).
1651
+ const buildSvgElements = (specs) => {
1652
+ const stack = [];
1653
+ const measureCanvas = document.createElement("canvas");
1654
+ const measureCtx = measureCanvas.getContext("2d");
1655
+
1656
+ for (const spec of specs || []) {
1657
+ if (!spec || typeof spec !== "object") continue;
1658
+ const type = spec.t;
1659
+
1660
+ if (
1661
+ type === "Empty" ||
1662
+ type === "Rectangle" ||
1663
+ type === "Ellipse" ||
1664
+ type === "CircularSector" ||
1665
+ type === "Triangle" ||
1666
+ type === "Text"
1667
+ ) {
1668
+ let width = 0, height = 0, pin = { x: 0, y: 0 };
1669
+ let svg = "";
1670
+
1671
+ if (type === "Rectangle") {
1672
+ width = Math.max(0, Number(spec.width) || 0);
1673
+ height = Math.max(0, Number(spec.height) || 0);
1674
+ const fill = colorToCss(spec.color);
1675
+ svg = `<rect x="${-width / 2}" y="${-height / 2}" width="${width}" height="${height}" fill="${fill}"/>`;
1676
+ } else if (type === "Ellipse") {
1677
+ width = Math.max(0, Number(spec.width) || 0);
1678
+ height = Math.max(0, Number(spec.height) || 0);
1679
+ const fill = colorToCss(spec.color);
1680
+ svg = `<ellipse cx="0" cy="0" rx="${width / 2}" ry="${height / 2}" fill="${fill}"/>`;
1681
+ } else if (type === "CircularSector") {
1682
+ const radius = Math.max(0, Number(spec.radius) || 0);
1683
+ const angle = Number(spec.angle) || 0;
1684
+ width = radius * 2;
1685
+ height = radius * 2;
1686
+ const fill = colorToCss(spec.color);
1687
+ // canvas: ctx.arc(0, 0, r, 0, -angle*PI/180, counterclockwise=true)
1688
+ // SVG arc from (r,0) counterclockwise to the same end-point (sweep=0)
1689
+ const endAngleRad = -angle * Math.PI / 180;
1690
+ const endX = radius * Math.cos(endAngleRad);
1691
+ const endY = radius * Math.sin(endAngleRad);
1692
+ const largeArcFlag = angle > 180 ? 1 : 0;
1693
+ svg = `<path d="M 0 0 L ${radius} 0 A ${radius} ${radius} 0 ${largeArcFlag} 0 ${endX} ${endY} Z" fill="${fill}"/>`;
1694
+ } else if (type === "Triangle") {
1695
+ const side1 = Math.max(0, Number(spec.side1) || 0);
1696
+ const side2 = Math.max(0, Number(spec.side2) || 0);
1697
+ const angle = (Number(spec.angle) || 0) * (Math.PI / 180);
1698
+ const p1 = { x: 0, y: 0 };
1699
+ const p2 = { x: side1, y: 0 };
1700
+ const p3 = { x: side2 * Math.cos(angle), y: -side2 * Math.sin(angle) };
1701
+ const centroid = { x: (p1.x + p2.x + p3.x) / 3, y: (p1.y + p2.y + p3.y) / 3 };
1702
+ const points = [p1, p2, p3].map((p) => ({ x: p.x - centroid.x, y: p.y - centroid.y }));
1703
+ const xs = points.map((p) => p.x);
1704
+ const ys = points.map((p) => p.y);
1705
+ width = Math.max(...xs) - Math.min(...xs);
1706
+ height = Math.max(...ys) - Math.min(...ys);
1707
+ const fill = colorToCss(spec.color);
1708
+ svg = `<polygon points="${points.map((p) => `${p.x},${p.y}`).join(" ")}" fill="${fill}"/>`;
1709
+ } else if (type === "Text") {
1710
+ const text = String(spec.text || "");
1711
+ const fontName = String(spec.font_name || "sans-serif");
1712
+ const textSize = Math.max(1, Number(spec.text_size) || 1);
1713
+ const fill = colorToCss(spec.color);
1714
+ measureCtx.font = `${textSize}px ${fontName}`;
1715
+ const metrics = measureCtx.measureText(text);
1716
+ width = Math.max(0, metrics.width || 0);
1717
+ const ascent = metrics.actualBoundingBoxAscent || textSize * 0.8;
1718
+ const descent = metrics.actualBoundingBoxDescent || textSize * 0.2;
1719
+ height = ascent + descent;
1720
+ pin = { x: -width / 2, y: (ascent - descent) / 2 };
1721
+ // y attribute is the text baseline; mirrors canvas fillText(-w/2, (ascent-descent)/2)
1722
+ svg = `<text x="${-width / 2}" y="${(ascent - descent) / 2}" font-family="${fontName}" font-size="${textSize}" fill="${fill}" text-anchor="start">${text}</text>`;
1723
+ }
1724
+
1725
+ stack.push({ width, height, pin, svg });
1726
+ continue;
1727
+ }
1728
+
1729
+ if (type === "Pin") {
1730
+ const child = stack.pop();
1731
+ if (!child) continue;
1732
+ const pin = decodePoint(spec.pin, child.width, child.height);
1733
+ stack.push({ ...child, pin });
1734
+ continue;
1735
+ }
1736
+
1737
+ if (type === "Rotate") {
1738
+ const child = stack.pop();
1739
+ if (!child) continue;
1740
+ const angleDeg = Number(spec.angle) || 0;
1741
+ const angleRad = (-angleDeg * Math.PI) / 180;
1742
+ const corners = [
1743
+ { x: -child.width / 2, y: -child.height / 2 },
1744
+ { x: child.width / 2, y: -child.height / 2 },
1745
+ { x: child.width / 2, y: child.height / 2 },
1746
+ { x: -child.width / 2, y: child.height / 2 },
1747
+ ].map((p) => rotatePoint(p, child.pin, angleRad));
1748
+ const minX = Math.min(...corners.map((p) => p.x));
1749
+ const maxX = Math.max(...corners.map((p) => p.x));
1750
+ const minY = Math.min(...corners.map((p) => p.y));
1751
+ const maxY = Math.max(...corners.map((p) => p.y));
1752
+ const center = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
1753
+ const offset = { x: -center.x, y: -center.y };
1754
+ const pin = { x: child.pin.x + offset.x, y: child.pin.y + offset.y };
1755
+ // SVG transform mirrors the canvas sequence: translate(offset) rotate(-angleDeg, pin)
1756
+ // SVG and canvas share the same y-down convention so signs are identical.
1757
+ const transform = `translate(${offset.x},${offset.y}) rotate(${-angleDeg},${child.pin.x},${child.pin.y})`;
1758
+ stack.push({
1759
+ width: maxX - minX,
1760
+ height: maxY - minY,
1761
+ pin,
1762
+ svg: `<g transform="${transform}">${child.svg}</g>`,
1763
+ });
1764
+ continue;
1765
+ }
1766
+
1767
+ if (type === "Compose") {
1768
+ const bg = stack.pop();
1769
+ const fg = stack.pop();
1770
+ if (!fg || !bg) continue;
1771
+ const fgPin = spec.fg_pin ? decodePoint(spec.fg_pin, fg.width, fg.height) : fg.pin;
1772
+ const bgPin = spec.bg_pin ? decodePoint(spec.bg_pin, bg.width, bg.height) : bg.pin;
1773
+ const bgCenter = { x: 0, y: 0 };
1774
+ const fgCenter = { x: bgPin.x - fgPin.x, y: bgPin.y - fgPin.y };
1775
+ const minX = Math.min(fgCenter.x - fg.width / 2, bgCenter.x - bg.width / 2);
1776
+ const maxX = Math.max(fgCenter.x + fg.width / 2, bgCenter.x + bg.width / 2);
1777
+ const minY = Math.min(fgCenter.y - fg.height / 2, bgCenter.y - bg.height / 2);
1778
+ const maxY = Math.max(fgCenter.y + fg.height / 2, bgCenter.y + bg.height / 2);
1779
+ const center = { x: (minX + maxX) / 2, y: (minY + maxY) / 2 };
1780
+ const fgOffset = { x: fgCenter.x - center.x, y: fgCenter.y - center.y };
1781
+ const bgOffset = { x: bgCenter.x - center.x, y: bgCenter.y - center.y };
1782
+ const pin = spec.pin
1783
+ ? decodePoint(spec.pin, maxX - minX, maxY - minY)
1784
+ : { x: bgPin.x - center.x, y: bgPin.y - center.y };
1785
+ // bg drawn first (behind), fg on top — same draw order as canvas
1786
+ stack.push({
1787
+ width: maxX - minX,
1788
+ height: maxY - minY,
1789
+ pin,
1790
+ svg: `<g transform="translate(${bgOffset.x},${bgOffset.y})">${bg.svg}</g><g transform="translate(${fgOffset.x},${fgOffset.y})">${fg.svg}</g>`,
1791
+ });
1792
+ }
1793
+ }
1794
+
1795
+ return stack[stack.length - 1] || { width: 0, height: 0, pin: { x: 0, y: 0 }, svg: "" };
1796
+ };
1797
+
1542
1798
  return {
1543
1799
  js_graphic_size: (specs) => {
1544
1800
  try {
@@ -1584,6 +1840,24 @@ hyperbook.python = (function () {
1584
1840
  throw e;
1585
1841
  }
1586
1842
  },
1843
+ js_svg_graphic: (specs, debug) => {
1844
+ try {
1845
+ const unproxiedSpecs = unProxy(specs);
1846
+ const result = buildSvgElements(unproxiedSpecs);
1847
+ const width = Math.max(1, Math.ceil(result.width));
1848
+ const height = Math.max(1, Math.ceil(result.height));
1849
+ let inner = result.svg;
1850
+ if (debug) {
1851
+ inner += `<rect x="${-width / 2}" y="${-height / 2}" width="${width}" height="${height}" fill="none" stroke="red" stroke-width="1"/>`;
1852
+ inner += `<line x1="${result.pin.x - 8}" y1="${result.pin.y}" x2="${result.pin.x + 8}" y2="${result.pin.y}" stroke="rgba(255,255,0,0.8)" stroke-width="1"/>`;
1853
+ inner += `<line x1="${result.pin.x}" y1="${result.pin.y - 8}" x2="${result.pin.x}" y2="${result.pin.y + 8}" stroke="rgba(255,255,0,0.8)" stroke-width="1"/>`;
1854
+ }
1855
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><g transform="translate(${width / 2},${height / 2})">${inner}</g></svg>`;
1856
+ } catch (e) {
1857
+ console.error("js_svg_graphic error:", e);
1858
+ throw e;
1859
+ }
1860
+ },
1587
1861
  js_save: (filename, content) => {
1588
1862
  const link = document.createElement("a");
1589
1863
  link.href = String(content || "");
@@ -2437,7 +2711,7 @@ if _pg:
2437
2711
  if (results) {
2438
2712
  appendOutput(output, results);
2439
2713
  } else if (error) {
2440
- appendOutput(output, error, true);
2714
+ appendFriendlyError(output, error, testCode);
2441
2715
  }
2442
2716
  }
2443
2717
  } catch (e) {
@@ -2485,7 +2759,7 @@ if _pg:
2485
2759
  appendOutput(output, results, false, id);
2486
2760
  } else if (error) {
2487
2761
  showOutput();
2488
- appendOutput(output, error, true, id);
2762
+ appendFriendlyError(output, error, script);
2489
2763
  }
2490
2764
  } else {
2491
2765
  appendOutputLine(id, "Execution stopped.");