@edadma/logo 0.2.5 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/main.js +16807 -15891
- package/dist/main.js.map +4 -4
- package/package.json +1 -1
- package/src/LogoAnimated.d.ts +79 -0
- package/src/index.d.ts +1 -0
- package/src/main/scala/io/github/edadma/logo/LogoAnimated.scala +451 -0
- package/src/main/scala/io/github/edadma/logo/LogoJS.scala +20 -8
package/package.json
CHANGED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { LogoDrawing, TurtleState } from './Logo'
|
|
2
|
+
|
|
3
|
+
/** Animated Logo interpreter with async execution support */
|
|
4
|
+
export declare class LogoAnimated {
|
|
5
|
+
constructor(canvas: HTMLCanvasElement)
|
|
6
|
+
|
|
7
|
+
// === Animation Control ===
|
|
8
|
+
|
|
9
|
+
/** Set the animation speed in milliseconds between yield points. 0 = instant (no animation). */
|
|
10
|
+
setSpeed(ms: number): void
|
|
11
|
+
|
|
12
|
+
/** Get the current animation speed */
|
|
13
|
+
getSpeed(): number
|
|
14
|
+
|
|
15
|
+
/** Check if execution is paused */
|
|
16
|
+
isPaused(): boolean
|
|
17
|
+
|
|
18
|
+
/** Check if execution is running */
|
|
19
|
+
isRunning(): boolean
|
|
20
|
+
|
|
21
|
+
/** Pause execution */
|
|
22
|
+
pause(): void
|
|
23
|
+
|
|
24
|
+
/** Resume execution after pause */
|
|
25
|
+
resume(): void
|
|
26
|
+
|
|
27
|
+
/** Stop execution completely */
|
|
28
|
+
stop(): void
|
|
29
|
+
|
|
30
|
+
// === Execution ===
|
|
31
|
+
|
|
32
|
+
/** Run a Logo program with animation (respects speed setting) */
|
|
33
|
+
run(program: string): void
|
|
34
|
+
|
|
35
|
+
/** Execute a single command synchronously (always instant, for REPL-style use) */
|
|
36
|
+
execute(command: string): string | undefined
|
|
37
|
+
|
|
38
|
+
/** Clear the canvas and reset turtle */
|
|
39
|
+
clear(): void
|
|
40
|
+
|
|
41
|
+
// === Callbacks ===
|
|
42
|
+
|
|
43
|
+
/** Set callback for each animation step (called after each yield point) */
|
|
44
|
+
onStep(callback: () => void): void
|
|
45
|
+
|
|
46
|
+
/** Set callback for completion (receives result or undefined) */
|
|
47
|
+
onComplete(callback: (result: string | undefined) => void): void
|
|
48
|
+
|
|
49
|
+
/** Set callback for errors */
|
|
50
|
+
onError(callback: (error: string) => void): void
|
|
51
|
+
|
|
52
|
+
/** Set output handler for print statements */
|
|
53
|
+
setOutputHandler(handler: (text: string) => void): void
|
|
54
|
+
|
|
55
|
+
/** Clear the output handler */
|
|
56
|
+
clearOutputHandler(): void
|
|
57
|
+
|
|
58
|
+
// === Rendering Settings ===
|
|
59
|
+
|
|
60
|
+
/** Enable or disable path-based rendering (smoother curves) */
|
|
61
|
+
setPathRendering(enabled: boolean): void
|
|
62
|
+
|
|
63
|
+
/** Set the canvas background color */
|
|
64
|
+
setBackgroundColor(color: string): void
|
|
65
|
+
|
|
66
|
+
/** Set the default pen color (for theme-aware drawing) */
|
|
67
|
+
setForegroundColor(color: string): void
|
|
68
|
+
|
|
69
|
+
/** Force a render */
|
|
70
|
+
render(): void
|
|
71
|
+
|
|
72
|
+
// === Variables ===
|
|
73
|
+
|
|
74
|
+
/** Set a global variable */
|
|
75
|
+
setVariable(name: string, value: any): void
|
|
76
|
+
|
|
77
|
+
/** Get a global variable */
|
|
78
|
+
getVariable(name: string): string | undefined
|
|
79
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
package io.github.edadma.logo
|
|
2
|
+
|
|
3
|
+
import scala.scalajs.js
|
|
4
|
+
import scala.scalajs.js.annotation.*
|
|
5
|
+
import org.scalajs.dom
|
|
6
|
+
import org.scalajs.dom.html
|
|
7
|
+
|
|
8
|
+
import scala.math.Pi
|
|
9
|
+
|
|
10
|
+
/** Animated Logo interpreter with async execution support for step-by-step animation */
|
|
11
|
+
@JSExportTopLevel("LogoAnimated")
|
|
12
|
+
class LogoAnimated(canvas: html.Canvas) extends js.Object:
|
|
13
|
+
private val ctx = canvas.getContext("2d").asInstanceOf[dom.CanvasRenderingContext2D]
|
|
14
|
+
|
|
15
|
+
// Settings
|
|
16
|
+
private var usePathRendering: Boolean = true
|
|
17
|
+
private var backgroundColor: String = "white"
|
|
18
|
+
private var foregroundColor: (Int, Int, Int) = (0, 0, 0)
|
|
19
|
+
private var isDarkMode: Boolean = false
|
|
20
|
+
|
|
21
|
+
// Animation state
|
|
22
|
+
private var speed: Int = 0 // ms delay between yield points (0 = instant)
|
|
23
|
+
private var paused: Boolean = false
|
|
24
|
+
private var stopped: Boolean = false
|
|
25
|
+
private var running: Boolean = false
|
|
26
|
+
private var pendingTimeout: Int = -1
|
|
27
|
+
|
|
28
|
+
// Current execution state
|
|
29
|
+
private var currentState: EvalResult = Done(LogoNull())
|
|
30
|
+
|
|
31
|
+
// Callbacks
|
|
32
|
+
private var onStepCb: js.UndefOr[js.Function0[Unit]] = js.undefined
|
|
33
|
+
private var onCompleteCb: js.UndefOr[js.Function1[js.UndefOr[String], Unit]] = js.undefined
|
|
34
|
+
private var onErrorCb: js.UndefOr[js.Function1[String, Unit]] = js.undefined
|
|
35
|
+
|
|
36
|
+
// The Logo interpreter
|
|
37
|
+
private val logo = new Logo:
|
|
38
|
+
def event(): Unit =
|
|
39
|
+
if speed > 0 then render()
|
|
40
|
+
|
|
41
|
+
// === Public API ===
|
|
42
|
+
|
|
43
|
+
/** Set the animation speed in milliseconds between yield points. 0 = instant (no animation). */
|
|
44
|
+
def setSpeed(ms: Int): Unit = speed = math.max(0, ms)
|
|
45
|
+
|
|
46
|
+
/** Get the current animation speed */
|
|
47
|
+
def getSpeed(): Int = speed
|
|
48
|
+
|
|
49
|
+
/** Check if execution is paused */
|
|
50
|
+
def isPaused(): Boolean = paused
|
|
51
|
+
|
|
52
|
+
/** Check if execution is running */
|
|
53
|
+
def isRunning(): Boolean = running
|
|
54
|
+
|
|
55
|
+
/** Pause execution */
|
|
56
|
+
def pause(): Unit = paused = true
|
|
57
|
+
|
|
58
|
+
/** Resume execution after pause */
|
|
59
|
+
def resume(): Unit =
|
|
60
|
+
if paused && running then
|
|
61
|
+
paused = false
|
|
62
|
+
continueExecution()
|
|
63
|
+
|
|
64
|
+
/** Stop execution completely */
|
|
65
|
+
def stop(): Unit =
|
|
66
|
+
stopped = true
|
|
67
|
+
paused = false
|
|
68
|
+
running = false
|
|
69
|
+
if pendingTimeout >= 0 then
|
|
70
|
+
dom.window.clearTimeout(pendingTimeout)
|
|
71
|
+
pendingTimeout = -1
|
|
72
|
+
|
|
73
|
+
/** Run a Logo program with animation */
|
|
74
|
+
def run(program: String): Unit =
|
|
75
|
+
stop() // Stop any existing execution
|
|
76
|
+
stopped = false
|
|
77
|
+
paused = false
|
|
78
|
+
running = true
|
|
79
|
+
|
|
80
|
+
if speed == 0 then
|
|
81
|
+
// Instant mode - run synchronously
|
|
82
|
+
try
|
|
83
|
+
val result = logo.interp(program)
|
|
84
|
+
render()
|
|
85
|
+
running = false
|
|
86
|
+
onCompleteCb.foreach { cb =>
|
|
87
|
+
result match
|
|
88
|
+
case LogoUnit => cb(js.undefined)
|
|
89
|
+
case v => cb(v.toString)
|
|
90
|
+
}
|
|
91
|
+
catch
|
|
92
|
+
case e: Throwable =>
|
|
93
|
+
running = false
|
|
94
|
+
onErrorCb.foreach(_(e.getMessage))
|
|
95
|
+
else
|
|
96
|
+
// Animated mode - run asynchronously
|
|
97
|
+
try
|
|
98
|
+
currentState = logo.interpStart(program)
|
|
99
|
+
continueExecution()
|
|
100
|
+
catch
|
|
101
|
+
case e: Throwable =>
|
|
102
|
+
running = false
|
|
103
|
+
onErrorCb.foreach(_(e.getMessage))
|
|
104
|
+
|
|
105
|
+
/** Execute a single command synchronously (always instant, for REPL-style use) */
|
|
106
|
+
def execute(command: String): js.UndefOr[String] =
|
|
107
|
+
try
|
|
108
|
+
val result = logo.interp(command)
|
|
109
|
+
render()
|
|
110
|
+
result match
|
|
111
|
+
case LogoUnit => js.undefined
|
|
112
|
+
case v => v.toString
|
|
113
|
+
catch
|
|
114
|
+
case e: Throwable =>
|
|
115
|
+
onErrorCb.foreach(_(e.getMessage))
|
|
116
|
+
js.undefined
|
|
117
|
+
|
|
118
|
+
/** Clear the screen and reset turtle */
|
|
119
|
+
def clear(): Unit =
|
|
120
|
+
stop()
|
|
121
|
+
logo.clearscreen()
|
|
122
|
+
render()
|
|
123
|
+
|
|
124
|
+
// === Callbacks ===
|
|
125
|
+
|
|
126
|
+
/** Set callback for each animation step (called after each yield point) */
|
|
127
|
+
def onStep(cb: js.Function0[Unit]): Unit = onStepCb = cb
|
|
128
|
+
|
|
129
|
+
/** Set callback for completion (receives result or undefined) */
|
|
130
|
+
def onComplete(cb: js.Function1[js.UndefOr[String], Unit]): Unit = onCompleteCb = cb
|
|
131
|
+
|
|
132
|
+
/** Set callback for errors */
|
|
133
|
+
def onError(cb: js.Function1[String, Unit]): Unit = onErrorCb = cb
|
|
134
|
+
|
|
135
|
+
/** Set output handler for print statements */
|
|
136
|
+
def setOutputHandler(handler: js.Function1[String, Unit]): Unit =
|
|
137
|
+
logo.setOutputHandler(s => handler(s))
|
|
138
|
+
|
|
139
|
+
/** Clear the output handler */
|
|
140
|
+
def clearOutputHandler(): Unit =
|
|
141
|
+
logo.clearOutputHandler()
|
|
142
|
+
|
|
143
|
+
// === Settings ===
|
|
144
|
+
|
|
145
|
+
/** Set whether to use path-based rendering (smoother) or line-based */
|
|
146
|
+
def setPathRendering(enabled: Boolean): Unit =
|
|
147
|
+
usePathRendering = enabled
|
|
148
|
+
render()
|
|
149
|
+
|
|
150
|
+
/** Set the canvas background color */
|
|
151
|
+
def setBackgroundColor(color: String): Unit =
|
|
152
|
+
backgroundColor = color
|
|
153
|
+
val (r, g, b) = parseColor(color)
|
|
154
|
+
val luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
155
|
+
isDarkMode = luminance < 0.5
|
|
156
|
+
render()
|
|
157
|
+
|
|
158
|
+
/** Set the default pen color (for theme-aware drawing) */
|
|
159
|
+
def setForegroundColor(color: String): Unit =
|
|
160
|
+
val rgb = parseColor(color)
|
|
161
|
+
foregroundColor = rgb
|
|
162
|
+
logo.setDefaultColor(rgb)
|
|
163
|
+
render()
|
|
164
|
+
|
|
165
|
+
/** Set a global variable */
|
|
166
|
+
def setVariable(name: String, value: Any): Unit = logo.setVariable(name, value)
|
|
167
|
+
|
|
168
|
+
/** Get a global variable */
|
|
169
|
+
def getVariable(name: String): js.UndefOr[String] =
|
|
170
|
+
logo.getVariable(name) match
|
|
171
|
+
case Some(v) => v.toString
|
|
172
|
+
case None => js.undefined
|
|
173
|
+
|
|
174
|
+
// === Internal Execution ===
|
|
175
|
+
|
|
176
|
+
private def continueExecution(): Unit =
|
|
177
|
+
if stopped then
|
|
178
|
+
running = false
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
if paused then
|
|
182
|
+
// Check again later
|
|
183
|
+
pendingTimeout = dom.window.setTimeout(() => continueExecution(), 50)
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
try
|
|
187
|
+
// Run steps until yield point (draw/print) or completion
|
|
188
|
+
var steps = 0
|
|
189
|
+
val maxSteps = 5000
|
|
190
|
+
|
|
191
|
+
while steps < maxSteps && !stopped && !paused do
|
|
192
|
+
currentState match
|
|
193
|
+
case Done(v) =>
|
|
194
|
+
running = false
|
|
195
|
+
render()
|
|
196
|
+
onCompleteCb.foreach { cb =>
|
|
197
|
+
v match
|
|
198
|
+
case LogoUnit => cb(js.undefined)
|
|
199
|
+
case LogoNull() => cb(js.undefined)
|
|
200
|
+
case other => cb(other.toString)
|
|
201
|
+
}
|
|
202
|
+
return
|
|
203
|
+
case _ =>
|
|
204
|
+
currentState = logo.trampolineStep(currentState)
|
|
205
|
+
steps += 1
|
|
206
|
+
|
|
207
|
+
// Check if we should yield (drawing OR output command)
|
|
208
|
+
if logo.shouldYield() then
|
|
209
|
+
render()
|
|
210
|
+
onStepCb.foreach(_())
|
|
211
|
+
pendingTimeout = dom.window.setTimeout(() => continueExecution(), speed)
|
|
212
|
+
return
|
|
213
|
+
|
|
214
|
+
// Yield to prevent blocking even without visible output
|
|
215
|
+
if !stopped then
|
|
216
|
+
pendingTimeout = dom.window.setTimeout(() => continueExecution(), 0)
|
|
217
|
+
|
|
218
|
+
catch
|
|
219
|
+
case e: Throwable =>
|
|
220
|
+
running = false
|
|
221
|
+
onErrorCb.foreach(_(e.getMessage))
|
|
222
|
+
|
|
223
|
+
// === Rendering ===
|
|
224
|
+
|
|
225
|
+
/** Force a render */
|
|
226
|
+
def render(): Unit =
|
|
227
|
+
val width = canvas.width
|
|
228
|
+
val height = canvas.height
|
|
229
|
+
|
|
230
|
+
ctx.fillStyle = backgroundColor
|
|
231
|
+
ctx.fillRect(0, 0, width, height)
|
|
232
|
+
|
|
233
|
+
ctx.save()
|
|
234
|
+
ctx.translate(width / 2.0, height / 2.0)
|
|
235
|
+
ctx.scale(1, -1)
|
|
236
|
+
|
|
237
|
+
if usePathRendering then renderWithPaths()
|
|
238
|
+
else renderWithLines()
|
|
239
|
+
|
|
240
|
+
logo.turtle match
|
|
241
|
+
case Some((x, y, heading)) => drawTurtle(x, y, heading)
|
|
242
|
+
case None =>
|
|
243
|
+
|
|
244
|
+
ctx.restore()
|
|
245
|
+
|
|
246
|
+
private def parseColor(color: String): (Int, Int, Int) =
|
|
247
|
+
ctx.fillStyle = color
|
|
248
|
+
val parsed = ctx.fillStyle.asInstanceOf[String]
|
|
249
|
+
if parsed.startsWith("#") then
|
|
250
|
+
val hex = parsed.drop(1)
|
|
251
|
+
if hex.length == 6 then
|
|
252
|
+
val r = Integer.parseInt(hex.substring(0, 2), 16)
|
|
253
|
+
val g = Integer.parseInt(hex.substring(2, 4), 16)
|
|
254
|
+
val b = Integer.parseInt(hex.substring(4, 6), 16)
|
|
255
|
+
(r, g, b)
|
|
256
|
+
else (0, 0, 0)
|
|
257
|
+
else (0, 0, 0)
|
|
258
|
+
|
|
259
|
+
private def renderWithPaths(): Unit =
|
|
260
|
+
case class Style(color: (Int, Int, Int), width: Double)
|
|
261
|
+
|
|
262
|
+
var currentColor: (Int, Int, Int) = foregroundColor
|
|
263
|
+
var currentWidth: Double = 1.0
|
|
264
|
+
var currentStyle: Option[Style] = None
|
|
265
|
+
var pathStarted = false
|
|
266
|
+
var lastX: Double = 0
|
|
267
|
+
var lastY: Double = 0
|
|
268
|
+
|
|
269
|
+
def flushPath(): Unit =
|
|
270
|
+
if pathStarted && currentStyle.isDefined then
|
|
271
|
+
val Style((r, g, b), width) = currentStyle.get
|
|
272
|
+
ctx.strokeStyle = s"rgb($r,$g,$b)"
|
|
273
|
+
ctx.lineWidth = width
|
|
274
|
+
ctx.lineCap = "round"
|
|
275
|
+
ctx.lineJoin = "round"
|
|
276
|
+
ctx.stroke()
|
|
277
|
+
pathStarted = false
|
|
278
|
+
currentStyle = None
|
|
279
|
+
|
|
280
|
+
logo.drawing.foreach {
|
|
281
|
+
case DrawSetColor(colorOpt) =>
|
|
282
|
+
currentColor = colorOpt.getOrElse(foregroundColor)
|
|
283
|
+
|
|
284
|
+
case DrawSetWidth(width) =>
|
|
285
|
+
currentWidth = width
|
|
286
|
+
|
|
287
|
+
case DrawLine(x1, y1, x2, y2) =>
|
|
288
|
+
val style = Style(currentColor, currentWidth)
|
|
289
|
+
|
|
290
|
+
if !currentStyle.contains(style) then
|
|
291
|
+
flushPath()
|
|
292
|
+
currentStyle = Some(style)
|
|
293
|
+
ctx.beginPath()
|
|
294
|
+
ctx.moveTo(x1, y1)
|
|
295
|
+
pathStarted = true
|
|
296
|
+
lastX = x1
|
|
297
|
+
lastY = y1
|
|
298
|
+
|
|
299
|
+
if !pathStarted || lastX != x1 || lastY != y1 then
|
|
300
|
+
if !pathStarted then
|
|
301
|
+
ctx.beginPath()
|
|
302
|
+
pathStarted = true
|
|
303
|
+
ctx.moveTo(x1, y1)
|
|
304
|
+
|
|
305
|
+
ctx.lineTo(x2, y2)
|
|
306
|
+
lastX = x2
|
|
307
|
+
lastY = y2
|
|
308
|
+
|
|
309
|
+
case DrawArc(x, y, heading, angleDeg, radius) =>
|
|
310
|
+
flushPath()
|
|
311
|
+
renderArc(x, y, heading, angleDeg, radius, currentColor, currentWidth)
|
|
312
|
+
|
|
313
|
+
case DrawLabel(x, y, heading, text) =>
|
|
314
|
+
flushPath()
|
|
315
|
+
renderLabel(x, y, heading, text)
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
flushPath()
|
|
319
|
+
|
|
320
|
+
private def renderWithLines(): Unit =
|
|
321
|
+
var currentColor: (Int, Int, Int) = foregroundColor
|
|
322
|
+
var currentWidth: Double = 1.0
|
|
323
|
+
|
|
324
|
+
logo.drawing.foreach {
|
|
325
|
+
case DrawSetColor(colorOpt) =>
|
|
326
|
+
currentColor = colorOpt.getOrElse(foregroundColor)
|
|
327
|
+
|
|
328
|
+
case DrawSetWidth(width) =>
|
|
329
|
+
currentWidth = width
|
|
330
|
+
|
|
331
|
+
case DrawLine(x1, y1, x2, y2) =>
|
|
332
|
+
val (r, g, b) = currentColor
|
|
333
|
+
ctx.strokeStyle = s"rgb($r,$g,$b)"
|
|
334
|
+
ctx.lineWidth = currentWidth
|
|
335
|
+
ctx.beginPath()
|
|
336
|
+
ctx.moveTo(x1, y1)
|
|
337
|
+
ctx.lineTo(x2, y2)
|
|
338
|
+
ctx.stroke()
|
|
339
|
+
|
|
340
|
+
case DrawArc(x, y, heading, angleDeg, radius) =>
|
|
341
|
+
renderArc(x, y, heading, angleDeg, radius, currentColor, currentWidth)
|
|
342
|
+
|
|
343
|
+
case DrawLabel(x, y, heading, text) =>
|
|
344
|
+
renderLabel(x, y, heading, text)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private def renderLabel(x: Double, y: Double, heading: Double, text: String): Unit =
|
|
348
|
+
ctx.save()
|
|
349
|
+
ctx.translate(x, y)
|
|
350
|
+
ctx.rotate(heading)
|
|
351
|
+
ctx.scale(1, -1)
|
|
352
|
+
ctx.fillStyle = "black"
|
|
353
|
+
ctx.font = "20px sans-serif"
|
|
354
|
+
ctx.fillText(text, 0, 0)
|
|
355
|
+
ctx.restore()
|
|
356
|
+
|
|
357
|
+
private def renderArc(x: Double, y: Double, heading: Double, angleDeg: Double, radius: Double, color: (Int, Int, Int), width: Double): Unit =
|
|
358
|
+
val (r, g, b) = color
|
|
359
|
+
val sign = if angleDeg >= 0 then 1.0 else -1.0
|
|
360
|
+
val absAngle = math.abs(angleDeg)
|
|
361
|
+
|
|
362
|
+
val perpAngle = heading + sign * Pi / 2
|
|
363
|
+
val cx = x + radius * math.cos(perpAngle)
|
|
364
|
+
val cy = y + radius * math.sin(perpAngle)
|
|
365
|
+
|
|
366
|
+
val startAngle = math.atan2(y - cy, x - cx)
|
|
367
|
+
|
|
368
|
+
val sweepRad = math.toRadians(absAngle)
|
|
369
|
+
val endAngle = if angleDeg >= 0 then startAngle - sweepRad else startAngle + sweepRad
|
|
370
|
+
|
|
371
|
+
ctx.strokeStyle = s"rgb($r,$g,$b)"
|
|
372
|
+
ctx.lineWidth = width
|
|
373
|
+
ctx.lineCap = "round"
|
|
374
|
+
ctx.beginPath()
|
|
375
|
+
ctx.arc(cx, cy, radius, startAngle, endAngle, angleDeg >= 0)
|
|
376
|
+
ctx.stroke()
|
|
377
|
+
|
|
378
|
+
private def drawTurtle(x: Double, y: Double, heading: Double): Unit =
|
|
379
|
+
ctx.save()
|
|
380
|
+
ctx.translate(x, y)
|
|
381
|
+
ctx.rotate(heading + Pi / 2)
|
|
382
|
+
|
|
383
|
+
val (shellFill, shellOutline, shellPattern, skinColor, skinOutline) =
|
|
384
|
+
if isDarkMode then
|
|
385
|
+
("#2d5a27", "#1a3a18", "#3d7a37", "#4a7a44", "#1a3a18")
|
|
386
|
+
else
|
|
387
|
+
("#5a9a50", "#3a6a38", "#7aba70", "#7ab070", "#3a6a38")
|
|
388
|
+
|
|
389
|
+
// Tail
|
|
390
|
+
ctx.beginPath()
|
|
391
|
+
ctx.moveTo(0, 10)
|
|
392
|
+
ctx.lineTo(0, 14)
|
|
393
|
+
ctx.strokeStyle = skinColor
|
|
394
|
+
ctx.lineWidth = 2
|
|
395
|
+
ctx.lineCap = "round"
|
|
396
|
+
ctx.stroke()
|
|
397
|
+
|
|
398
|
+
// Legs
|
|
399
|
+
ctx.fillStyle = skinColor
|
|
400
|
+
ctx.strokeStyle = skinOutline
|
|
401
|
+
ctx.lineWidth = 1
|
|
402
|
+
ctx.beginPath()
|
|
403
|
+
ctx.ellipse(-7, -6, 3, 5, 0.4, 0, 2 * Pi)
|
|
404
|
+
ctx.fill()
|
|
405
|
+
ctx.stroke()
|
|
406
|
+
ctx.beginPath()
|
|
407
|
+
ctx.ellipse(7, -6, 3, 5, -0.4, 0, 2 * Pi)
|
|
408
|
+
ctx.fill()
|
|
409
|
+
ctx.stroke()
|
|
410
|
+
ctx.beginPath()
|
|
411
|
+
ctx.ellipse(-6, 6, 3, 4, 0.3, 0, 2 * Pi)
|
|
412
|
+
ctx.fill()
|
|
413
|
+
ctx.stroke()
|
|
414
|
+
ctx.beginPath()
|
|
415
|
+
ctx.ellipse(6, 6, 3, 4, -0.3, 0, 2 * Pi)
|
|
416
|
+
ctx.fill()
|
|
417
|
+
ctx.stroke()
|
|
418
|
+
|
|
419
|
+
// Head
|
|
420
|
+
ctx.beginPath()
|
|
421
|
+
ctx.ellipse(0, -14, 4, 5, 0, 0, 2 * Pi)
|
|
422
|
+
ctx.fillStyle = skinColor
|
|
423
|
+
ctx.fill()
|
|
424
|
+
ctx.strokeStyle = skinOutline
|
|
425
|
+
ctx.lineWidth = 1.5
|
|
426
|
+
ctx.stroke()
|
|
427
|
+
|
|
428
|
+
// Eyes
|
|
429
|
+
ctx.fillStyle = "black"
|
|
430
|
+
ctx.beginPath()
|
|
431
|
+
ctx.arc(-1.5, -15, 1, 0, 2 * Pi)
|
|
432
|
+
ctx.arc(1.5, -15, 1, 0, 2 * Pi)
|
|
433
|
+
ctx.fill()
|
|
434
|
+
|
|
435
|
+
// Shell
|
|
436
|
+
ctx.beginPath()
|
|
437
|
+
ctx.ellipse(0, 0, 8, 10, 0, 0, 2 * Pi)
|
|
438
|
+
ctx.fillStyle = shellFill
|
|
439
|
+
ctx.fill()
|
|
440
|
+
ctx.strokeStyle = shellOutline
|
|
441
|
+
ctx.lineWidth = 1.5
|
|
442
|
+
ctx.stroke()
|
|
443
|
+
|
|
444
|
+
// Shell pattern
|
|
445
|
+
ctx.strokeStyle = shellPattern
|
|
446
|
+
ctx.lineWidth = 1
|
|
447
|
+
ctx.beginPath()
|
|
448
|
+
ctx.ellipse(0, 0, 5, 6, 0, 0, 2 * Pi)
|
|
449
|
+
ctx.stroke()
|
|
450
|
+
|
|
451
|
+
ctx.restore()
|
|
@@ -58,6 +58,7 @@ class LogoJS(canvas: html.Canvas) extends js.Object:
|
|
|
58
58
|
private var initialized: Boolean = false
|
|
59
59
|
private var backgroundColor: String = "white"
|
|
60
60
|
private var foregroundColor: (Int, Int, Int) = (0, 0, 0) // RGB for theme-aware drawing
|
|
61
|
+
private var isDarkMode: Boolean = false
|
|
61
62
|
private var eventHandler: Option[js.Function0[Unit]] = None
|
|
62
63
|
|
|
63
64
|
private val logo = new Logo:
|
|
@@ -99,6 +100,10 @@ class LogoJS(canvas: html.Canvas) extends js.Object:
|
|
|
99
100
|
/** Set the canvas background color */
|
|
100
101
|
def setBackgroundColor(color: String): Unit =
|
|
101
102
|
backgroundColor = color
|
|
103
|
+
val (r, g, b) = parseColor(color)
|
|
104
|
+
// Calculate luminance to determine if dark mode
|
|
105
|
+
val luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
106
|
+
isDarkMode = luminance < 0.5
|
|
102
107
|
render()
|
|
103
108
|
|
|
104
109
|
/** Set the default pen color (used after clear and for theme-aware drawing) */
|
|
@@ -348,18 +353,25 @@ class LogoJS(canvas: html.Canvas) extends js.Object:
|
|
|
348
353
|
ctx.translate(x, y)
|
|
349
354
|
ctx.rotate(heading + Pi / 2)
|
|
350
355
|
|
|
356
|
+
// Theme-aware colors
|
|
357
|
+
val (shellFill, shellOutline, shellPattern, skinColor, skinOutline) =
|
|
358
|
+
if isDarkMode then
|
|
359
|
+
("#2d5a27", "#1a3a18", "#3d7a37", "#4a7a44", "#1a3a18")
|
|
360
|
+
else
|
|
361
|
+
("#5a9a50", "#3a6a38", "#7aba70", "#7ab070", "#3a6a38")
|
|
362
|
+
|
|
351
363
|
// Tail (behind shell)
|
|
352
364
|
ctx.beginPath()
|
|
353
365
|
ctx.moveTo(0, 10)
|
|
354
366
|
ctx.lineTo(0, 14)
|
|
355
|
-
ctx.strokeStyle =
|
|
367
|
+
ctx.strokeStyle = skinColor
|
|
356
368
|
ctx.lineWidth = 2
|
|
357
369
|
ctx.lineCap = "round"
|
|
358
370
|
ctx.stroke()
|
|
359
371
|
|
|
360
372
|
// Legs (behind shell)
|
|
361
|
-
ctx.fillStyle =
|
|
362
|
-
ctx.strokeStyle =
|
|
373
|
+
ctx.fillStyle = skinColor
|
|
374
|
+
ctx.strokeStyle = skinOutline
|
|
363
375
|
ctx.lineWidth = 1
|
|
364
376
|
// Front legs
|
|
365
377
|
ctx.beginPath()
|
|
@@ -383,9 +395,9 @@ class LogoJS(canvas: html.Canvas) extends js.Object:
|
|
|
383
395
|
// Head (behind shell)
|
|
384
396
|
ctx.beginPath()
|
|
385
397
|
ctx.ellipse(0, -14, 4, 5, 0, 0, 2 * Pi)
|
|
386
|
-
ctx.fillStyle =
|
|
398
|
+
ctx.fillStyle = skinColor
|
|
387
399
|
ctx.fill()
|
|
388
|
-
ctx.strokeStyle =
|
|
400
|
+
ctx.strokeStyle = skinOutline
|
|
389
401
|
ctx.lineWidth = 1.5
|
|
390
402
|
ctx.stroke()
|
|
391
403
|
|
|
@@ -399,14 +411,14 @@ class LogoJS(canvas: html.Canvas) extends js.Object:
|
|
|
399
411
|
// Shell (on top)
|
|
400
412
|
ctx.beginPath()
|
|
401
413
|
ctx.ellipse(0, 0, 8, 10, 0, 0, 2 * Pi)
|
|
402
|
-
ctx.fillStyle =
|
|
414
|
+
ctx.fillStyle = shellFill
|
|
403
415
|
ctx.fill()
|
|
404
|
-
ctx.strokeStyle =
|
|
416
|
+
ctx.strokeStyle = shellOutline
|
|
405
417
|
ctx.lineWidth = 1.5
|
|
406
418
|
ctx.stroke()
|
|
407
419
|
|
|
408
420
|
// Shell pattern
|
|
409
|
-
ctx.strokeStyle =
|
|
421
|
+
ctx.strokeStyle = shellPattern
|
|
410
422
|
ctx.lineWidth = 1
|
|
411
423
|
ctx.beginPath()
|
|
412
424
|
ctx.ellipse(0, 0, 5, 6, 0, 0, 2 * Pi)
|