domma-cms 0.1.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.
Files changed (135) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +469 -0
  3. package/admin/css/admin.css +1123 -0
  4. package/admin/index.html +72 -0
  5. package/admin/js/api.js +210 -0
  6. package/admin/js/app.js +270 -0
  7. package/admin/js/config/sidebar-config.js +107 -0
  8. package/admin/js/lib/card.js +63 -0
  9. package/admin/js/lib/image-editor.js +869 -0
  10. package/admin/js/lib/markdown-toolbar.js +421 -0
  11. package/admin/js/templates/dashboard.html +50 -0
  12. package/admin/js/templates/documentation.html +237 -0
  13. package/admin/js/templates/layouts.html +11 -0
  14. package/admin/js/templates/login.html +58 -0
  15. package/admin/js/templates/media.html +16 -0
  16. package/admin/js/templates/navigation.html +50 -0
  17. package/admin/js/templates/page-editor.html +126 -0
  18. package/admin/js/templates/pages.html +18 -0
  19. package/admin/js/templates/plugins.html +12 -0
  20. package/admin/js/templates/settings.html +190 -0
  21. package/admin/js/templates/tutorials.html +233 -0
  22. package/admin/js/templates/user-editor.html +12 -0
  23. package/admin/js/templates/users.html +10 -0
  24. package/admin/js/views/dashboard.js +48 -0
  25. package/admin/js/views/documentation.js +12 -0
  26. package/admin/js/views/index.js +33 -0
  27. package/admin/js/views/layouts.js +49 -0
  28. package/admin/js/views/login.js +254 -0
  29. package/admin/js/views/media.js +240 -0
  30. package/admin/js/views/navigation.js +152 -0
  31. package/admin/js/views/page-editor.js +479 -0
  32. package/admin/js/views/pages.js +64 -0
  33. package/admin/js/views/plugins.js +100 -0
  34. package/admin/js/views/settings.js +64 -0
  35. package/admin/js/views/tutorials.js +12 -0
  36. package/admin/js/views/user-editor.js +88 -0
  37. package/admin/js/views/users.js +73 -0
  38. package/bin/cli.js +334 -0
  39. package/config/auth.json +20 -0
  40. package/config/content.json +10 -0
  41. package/config/navigation.json +63 -0
  42. package/config/plugins.json +47 -0
  43. package/config/presets.json +34 -0
  44. package/config/server.json +6 -0
  45. package/config/site.json +33 -0
  46. package/package.json +67 -0
  47. package/plugins/back-to-top/admin/templates/back-to-top-settings.html +55 -0
  48. package/plugins/back-to-top/admin/views/back-to-top-settings.js +44 -0
  49. package/plugins/back-to-top/config.js +10 -0
  50. package/plugins/back-to-top/plugin.js +24 -0
  51. package/plugins/back-to-top/plugin.json +36 -0
  52. package/plugins/back-to-top/public/inject-body.html +105 -0
  53. package/plugins/cookie-consent/admin/templates/cookie-consent-settings.html +113 -0
  54. package/plugins/cookie-consent/admin/views/cookie-consent-settings.js +73 -0
  55. package/plugins/cookie-consent/config.js +30 -0
  56. package/plugins/cookie-consent/plugin.js +24 -0
  57. package/plugins/cookie-consent/plugin.json +36 -0
  58. package/plugins/cookie-consent/public/inject-body.html +69 -0
  59. package/plugins/custom-css/admin/templates/custom-css.html +17 -0
  60. package/plugins/custom-css/admin/views/custom-css.js +35 -0
  61. package/plugins/custom-css/config.js +1 -0
  62. package/plugins/custom-css/data/custom.css +0 -0
  63. package/plugins/custom-css/plugin.js +63 -0
  64. package/plugins/custom-css/plugin.json +32 -0
  65. package/plugins/custom-css/public/inject-head.html +1 -0
  66. package/plugins/domma-effects/admin/templates/domma-effects.html +488 -0
  67. package/plugins/domma-effects/admin/views/domma-effects.js +56 -0
  68. package/plugins/domma-effects/config.js +9 -0
  69. package/plugins/domma-effects/plugin.js +22 -0
  70. package/plugins/domma-effects/plugin.json +36 -0
  71. package/plugins/domma-effects/public/celebrations/core/canvas.js +111 -0
  72. package/plugins/domma-effects/public/celebrations/core/particles.js +144 -0
  73. package/plugins/domma-effects/public/celebrations/core/physics.js +166 -0
  74. package/plugins/domma-effects/public/celebrations/index.js +535 -0
  75. package/plugins/domma-effects/public/celebrations/themes/christmas.js +1805 -0
  76. package/plugins/domma-effects/public/celebrations/themes/guy-fawkes.js +1477 -0
  77. package/plugins/domma-effects/public/celebrations/themes/halloween.js +1837 -0
  78. package/plugins/domma-effects/public/celebrations/themes/st-andrews.js +1175 -0
  79. package/plugins/domma-effects/public/celebrations/themes/st-davids.js +1258 -0
  80. package/plugins/domma-effects/public/celebrations/themes/st-georges.js +1754 -0
  81. package/plugins/domma-effects/public/celebrations/themes/st-patricks.js +1290 -0
  82. package/plugins/domma-effects/public/celebrations/themes/valentines.js +1361 -0
  83. package/plugins/domma-effects/public/inject-body.html +268 -0
  84. package/plugins/example-analytics/admin/templates/analytics.html +10 -0
  85. package/plugins/example-analytics/admin/views/analytics.js +51 -0
  86. package/plugins/example-analytics/config.js +6 -0
  87. package/plugins/example-analytics/plugin.js +58 -0
  88. package/plugins/example-analytics/plugin.json +27 -0
  89. package/plugins/example-analytics/public/inject-body.html +13 -0
  90. package/plugins/example-analytics/public/inject-head.html +1 -0
  91. package/plugins/example-analytics/stats.json +1 -0
  92. package/plugins/form-builder/admin/templates/form-editor.html +158 -0
  93. package/plugins/form-builder/admin/templates/form-settings.html +29 -0
  94. package/plugins/form-builder/admin/templates/form-submissions.html +30 -0
  95. package/plugins/form-builder/admin/templates/forms-list.html +17 -0
  96. package/plugins/form-builder/admin/views/form-editor.js +817 -0
  97. package/plugins/form-builder/admin/views/form-settings.js +38 -0
  98. package/plugins/form-builder/admin/views/form-submissions.js +295 -0
  99. package/plugins/form-builder/admin/views/forms-list.js +164 -0
  100. package/plugins/form-builder/config.js +9 -0
  101. package/plugins/form-builder/data/forms/contact-details.json +63 -0
  102. package/plugins/form-builder/data/forms/contact.json +52 -0
  103. package/plugins/form-builder/data/submissions/contact-details.json +1 -0
  104. package/plugins/form-builder/data/submissions/contact.json +14 -0
  105. package/plugins/form-builder/email.js +103 -0
  106. package/plugins/form-builder/plugin.js +454 -0
  107. package/plugins/form-builder/plugin.json +56 -0
  108. package/plugins/form-builder/public/inject-body.html +270 -0
  109. package/plugins/form-builder/public/inject-head.html +42 -0
  110. package/public/css/site.css +189 -0
  111. package/public/js/site.js +109 -0
  112. package/scripts/copy-domma.js +48 -0
  113. package/scripts/fresh.js +41 -0
  114. package/scripts/reset.js +124 -0
  115. package/scripts/seed.js +666 -0
  116. package/scripts/setup.js +263 -0
  117. package/server/config.js +56 -0
  118. package/server/middleware/auth.js +97 -0
  119. package/server/routes/api/auth.js +116 -0
  120. package/server/routes/api/layouts.js +25 -0
  121. package/server/routes/api/media.js +93 -0
  122. package/server/routes/api/navigation.js +37 -0
  123. package/server/routes/api/pages.js +118 -0
  124. package/server/routes/api/plugins.js +46 -0
  125. package/server/routes/api/settings.js +25 -0
  126. package/server/routes/api/users.js +110 -0
  127. package/server/routes/public.js +108 -0
  128. package/server/server.js +169 -0
  129. package/server/services/content.js +298 -0
  130. package/server/services/images.js +334 -0
  131. package/server/services/markdown.js +297 -0
  132. package/server/services/plugins.js +246 -0
  133. package/server/services/renderer.js +80 -0
  134. package/server/services/users.js +212 -0
  135. package/server/templates/page.html +78 -0
@@ -0,0 +1,1175 @@
1
+ /**
2
+ * St Andrew's Day Theme for Domma Celebrations
3
+ * (November 30th, Scottish Celebration)
4
+ *
5
+ * Features:
6
+ * - Falling thistles (Scottish national flower)
7
+ * - Snowflakes (Scottish winter)
8
+ * - Saltire flag patterns (white X on blue)
9
+ * - Bagpiper silhouette
10
+ * - Tartan patterns
11
+ * - Blue and white color scheme with purple thistles
12
+ */
13
+
14
+ export default {
15
+ name: 'st-andrews',
16
+ displayName: 'St Andrew\'s Day',
17
+ emoji: '🏴󠁧󠁢󠁳󠁣󠁴󠁿',
18
+
19
+ // Intensity configurations
20
+ intensityConfig: {
21
+ light: {
22
+ count: 50,
23
+ speedRange: [0.5, 1.5],
24
+ sizeRange: [2, 4],
25
+ thistles: 4,
26
+ bagpiperChance: 0.0002,
27
+ twinklingStars: 12
28
+ },
29
+ medium: {
30
+ count: 100,
31
+ speedRange: [0.6, 2.0],
32
+ sizeRange: [2, 5],
33
+ thistles: 6,
34
+ bagpiperChance: 0.0004,
35
+ twinklingStars: 20
36
+ },
37
+ heavy: {
38
+ count: 200,
39
+ speedRange: [0.8, 2.5],
40
+ sizeRange: [3, 6],
41
+ thistles: 8,
42
+ bagpiperChance: 0.0006,
43
+ twinklingStars: 30
44
+ }
45
+ },
46
+
47
+ particles: ['heather-petal', 'heather', 'saltire-sparkle'],
48
+ decorations: ['thistle-plant', 'bagpiper', 'saltire-flag', 'tartan-pattern', 'highland-scene', 'twinkling-star'],
49
+ colors: {
50
+ primary: '#0065BD', // Scottish blue (Saltire)
51
+ secondary: '#FFFFFF', // White (Saltire)
52
+ accent: '#8B008B', // Thistle purple (decorations only)
53
+ tartan: ['#0065BD', '#006400', '#8B0000', '#FFDD00']
54
+ },
55
+
56
+ /**
57
+ * Create heather petal particle (simple purple petal)
58
+ */
59
+ createHeatherPetal(canvasWidth, canvasHeight, config) {
60
+ // Purple/pink heather shades
61
+ const purpleShades = ['#9370DB', '#BA55D3', '#DA70D6', '#DDA0DD', '#8B008B'];
62
+ return {
63
+ type: 'heather-petal',
64
+ x: -30, // Start from left edge
65
+ y: Math.random() * canvasHeight, // Random height
66
+ vx: config.speedRange[0] + Math.random() * (config.speedRange[1] - config.speedRange[0]), // Horizontal drift
67
+ size: (config.sizeRange[0] + Math.random() * (config.sizeRange[1] - config.sizeRange[0])) * 1.5, // Larger petals
68
+ speed: (Math.random() - 0.5) * 0.2, // Gentle vertical bobbing
69
+ opacity: 0.75 + Math.random() * 0.25,
70
+ windOffset: Math.random() * Math.PI * 2,
71
+ windSpeed: 0.015 + Math.random() * 0.02,
72
+ rotation: Math.random() * Math.PI * 2,
73
+ rotationSpeed: (Math.random() - 0.5) * 0.03,
74
+ color: purpleShades[Math.floor(Math.random() * purpleShades.length)],
75
+ flutter: Math.random() * Math.PI * 2,
76
+ flutterSpeed: 0.02 + Math.random() * 0.02,
77
+ active: true
78
+ };
79
+ },
80
+
81
+ /**
82
+ * Create thistle particle (white and blue - Scottish Saltire colors - full flower)
83
+ */
84
+ createThistle(canvasWidth, canvasHeight, config) {
85
+ const isWhite = Math.random() < 0.5; // 50% white, 50% blue
86
+ const flowerColor = isWhite ? '#FFFFFF' : '#0065BD';
87
+ const petalColors = isWhite
88
+ ? ['#FFFFFF', '#f0f0f0', '#e0e0e0'] // White shades
89
+ : ['#0065BD', '#1a7dd4', '#3399ee']; // Blue shades
90
+
91
+ return {
92
+ type: 'thistle',
93
+ x: -30, // Start from left edge
94
+ y: Math.random() * canvasHeight, // Random height
95
+ vx: config.speedRange[0] + Math.random() * (config.speedRange[1] - config.speedRange[0]) * 0.7, // Horizontal drift
96
+ size: config.sizeRange[0] + Math.random() * (config.sizeRange[1] - config.sizeRange[0]),
97
+ speed: (Math.random() - 0.5) * 0.15, // Gentle vertical bobbing
98
+ opacity: 0.7 + Math.random() * 0.3,
99
+ windOffset: Math.random() * Math.PI * 2,
100
+ windSpeed: 0.015 + Math.random() * 0.02,
101
+ rotation: Math.random() * Math.PI * 2,
102
+ rotationSpeed: (Math.random() - 0.5) * 0.02,
103
+ flowerColor: flowerColor,
104
+ petalColors: petalColors,
105
+ active: true
106
+ };
107
+ },
108
+
109
+ /**
110
+ * Create heather particle (Scottish Highland heather - purple flowers - full sprig)
111
+ */
112
+ createHeather(canvasWidth, canvasHeight, config) {
113
+ // Heather color variations (purple/pink shades)
114
+ const colorChoice = Math.random();
115
+ let color;
116
+ if (colorChoice < 0.5) {
117
+ color = '#9370DB'; // 50% medium purple
118
+ } else if (colorChoice < 0.8) {
119
+ color = '#BA55D3'; // 30% medium orchid
120
+ } else {
121
+ color = '#DA70D6'; // 20% orchid pink
122
+ }
123
+
124
+ return {
125
+ type: 'heather',
126
+ x: -30, // Start from left edge
127
+ y: Math.random() * canvasHeight, // Random height
128
+ vx: (config.speedRange[0] + Math.random() * (config.speedRange[1] - config.speedRange[0])) * 0.6, // Horizontal drift
129
+ size: config.sizeRange[0] + Math.random() * (config.sizeRange[1] - config.sizeRange[0]),
130
+ speed: (Math.random() - 0.5) * 0.15, // Gentle vertical bobbing
131
+ opacity: 0.75 + Math.random() * 0.25,
132
+ windOffset: Math.random() * Math.PI * 2,
133
+ windSpeed: 0.02 + Math.random() * 0.02,
134
+ rotation: Math.random() * Math.PI * 2,
135
+ rotationSpeed: (Math.random() - 0.5) * 0.03,
136
+ color: color,
137
+ sway: Math.random() * Math.PI * 2,
138
+ swaySpeed: 0.02 + Math.random() * 0.02,
139
+ bellCount: 3 + Math.floor(Math.random() * 3), // 3-5 bells per sprig
140
+ active: true
141
+ };
142
+ },
143
+
144
+ /**
145
+ * Create saltire sparkle particle
146
+ */
147
+ createSaltireSparkle(canvasWidth, canvasHeight, config) {
148
+ const colors = ['#0065BD', '#FFFFFF']; // Scottish blue and white
149
+ return {
150
+ type: 'saltire-sparkle',
151
+ x: -20, // Start from left edge
152
+ y: Math.random() * canvasHeight, // Random height
153
+ vx: (config.speedRange[0] + Math.random() * (config.speedRange[1] - config.speedRange[0])) * 0.8, // Horizontal drift
154
+ size: config.sizeRange[0] + Math.random() * (config.sizeRange[1] - config.sizeRange[0]) * 0.6,
155
+ vy: (Math.random() - 0.5) * 0.2, // Minimal random vertical movement
156
+ opacity: 0.6 + Math.random() * 0.4,
157
+ rotation: Math.random() * Math.PI * 2,
158
+ rotationSpeed: (Math.random() - 0.5) * 0.04,
159
+ color: colors[Math.floor(Math.random() * colors.length)],
160
+ twinklePhase: Math.random() * Math.PI * 2,
161
+ windOffset: Math.random() * Math.PI * 2,
162
+ windSpeed: 0.015 + Math.random() * 0.02,
163
+ active: true,
164
+ static: false
165
+ };
166
+ },
167
+
168
+ /**
169
+ * Create drifting particle (randomly picks type)
170
+ * Note: St. Andrew's Day particles drift horizontally (left-to-right), not vertically
171
+ */
172
+ createFallingParticle(canvasWidth, canvasHeight, config) {
173
+ const choice = Math.random();
174
+
175
+ // 60% heather petals, 20% full heather, 15% sparkles, 5% thistles
176
+ if (choice < 0.6) {
177
+ return this.createHeatherPetal(canvasWidth, canvasHeight, config);
178
+ } else if (choice < 0.8) {
179
+ return this.createHeather(canvasWidth, canvasHeight, config);
180
+ } else if (choice < 0.95) {
181
+ return this.createSaltireSparkle(canvasWidth, canvasHeight, config);
182
+ } else {
183
+ return this.createThistle(canvasWidth, canvasHeight, config);
184
+ }
185
+ },
186
+
187
+ /**
188
+ * Create static thistle plant decoration
189
+ */
190
+ createThistlePlant(canvasWidth, canvasHeight, options = {}) {
191
+ return {
192
+ type: 'thistle-plant',
193
+ x: options.x !== undefined ? options.x : Math.random() * canvasWidth,
194
+ y: options.y !== undefined ? options.y : canvasHeight - 40,
195
+ size: 15 + Math.random() * 10,
196
+ opacity: 0.8 + Math.random() * 0.2,
197
+ swayPhase: Math.random() * Math.PI * 2,
198
+ active: true,
199
+ static: true
200
+ };
201
+ },
202
+
203
+ /**
204
+ * Create initial static decorations (Scottish-themed)
205
+ */
206
+ createInitialDecorations(canvasWidth, canvasHeight, config) {
207
+ const decorations = [];
208
+
209
+ // Thistle plants (Scottish national flower, multiple positions)
210
+ const thistleCount = config.thistles || 5;
211
+ for (let i = 0; i < thistleCount; i++) {
212
+ decorations.push(this.createThistlePlant(canvasWidth, canvasHeight, {
213
+ x: 80 + (i / (thistleCount - 1)) * (canvasWidth - 160),
214
+ y: canvasHeight - 60 - Math.random() * 30
215
+ }));
216
+ }
217
+
218
+ // Bagpiper (Highland piper in traditional dress, left side)
219
+ decorations.push({
220
+ type: 'bagpiper',
221
+ x: 150,
222
+ y: canvasHeight - 60,
223
+ baseY: canvasHeight - 60,
224
+ vx: 0, // Static display
225
+ size: 30 + Math.random() * 8,
226
+ opacity: 1,
227
+ time: 0,
228
+ legPhase: 0,
229
+ bagInflate: 0.5,
230
+ active: true,
231
+ static: true
232
+ });
233
+
234
+ // Saltire flag (St Andrew's Cross, top right)
235
+ decorations.push({
236
+ type: 'saltire-flag',
237
+ x: canvasWidth - 100,
238
+ y: 80,
239
+ size: 60,
240
+ opacity: 1,
241
+ wavePhase: Math.random() * Math.PI * 2,
242
+ active: true,
243
+ static: true
244
+ });
245
+
246
+ // Tartan pattern display (decorative Scottish pattern, bottom center)
247
+ decorations.push({
248
+ type: 'tartan-pattern',
249
+ x: canvasWidth * 0.5,
250
+ y: canvasHeight - 80,
251
+ size: 80 + Math.random() * 20,
252
+ opacity: 0.9,
253
+ active: true,
254
+ static: true
255
+ });
256
+
257
+ // Highland scene silhouette (mountains, castle, loch - right side)
258
+ decorations.push({
259
+ type: 'highland-scene',
260
+ x: canvasWidth - 200,
261
+ y: canvasHeight - 150,
262
+ size: 100,
263
+ opacity: 0.7,
264
+ active: true,
265
+ static: true
266
+ });
267
+
268
+ // Create twinkling stars (Scottish winter night sky)
269
+ const starCount = config.twinklingStars || 20;
270
+ for (let i = 0; i < starCount; i++) {
271
+ decorations.push(this.createTwinklingStar(canvasWidth, canvasHeight));
272
+ }
273
+
274
+ return decorations;
275
+ },
276
+
277
+ /**
278
+ * Create twinkling star particle
279
+ */
280
+ createTwinklingStar(canvasWidth, canvasHeight) {
281
+ return {
282
+ type: 'twinkling-star',
283
+ x: Math.random() * canvasWidth,
284
+ y: Math.random() * (canvasHeight * 0.6), // Upper portion of sky
285
+ size: 1 + Math.random() * 2,
286
+ opacity: 0.6 + Math.random() * 0.3,
287
+ twinklePhase: Math.random() * Math.PI * 2,
288
+ twinkleSpeed: 0.003 + Math.random() * 0.003,
289
+ active: true,
290
+ static: true
291
+ };
292
+ },
293
+
294
+ /**
295
+ * Spawn special St Andrew's particles
296
+ */
297
+ spawnSpecialParticle(specialParticles, canvasWidth, canvasHeight, config) {
298
+ const choice = Math.random();
299
+
300
+ // Bagpiper (very rare, max 1)
301
+ if (choice < config.bagpiperChance) {
302
+ if (specialParticles.some(p => p.type === 'bagpiper')) {
303
+ return null;
304
+ }
305
+ const fromLeft = Math.random() < 0.5;
306
+ return {
307
+ type: 'bagpiper',
308
+ x: fromLeft ? -60 : canvasWidth + 60,
309
+ y: canvasHeight - 50,
310
+ vx: fromLeft ? 0.8 + Math.random() * 0.5 : -(0.8 + Math.random() * 0.5),
311
+ size: 18 + Math.random() * 8,
312
+ opacity: 0.9,
313
+ time: 0,
314
+ marchPhase: Math.random() * Math.PI * 2,
315
+ active: true,
316
+ static: false
317
+ };
318
+ }
319
+
320
+ // Saltire flag (rare, max 2)
321
+ if (choice < 0.0004) {
322
+ const flagCount = specialParticles.filter(p => p.type === 'saltire-flag').length;
323
+ if (flagCount < 2) {
324
+ return {
325
+ type: 'saltire-flag',
326
+ x: Math.random() * canvasWidth * 0.6 + canvasWidth * 0.2,
327
+ y: 50 + Math.random() * 100,
328
+ size: 40 + Math.random() * 20,
329
+ opacity: 0.8,
330
+ waveOffset: Math.random() * Math.PI * 2,
331
+ time: 0,
332
+ active: true,
333
+ static: false
334
+ };
335
+ }
336
+ }
337
+
338
+ // Sparkles
339
+ if (choice < 0.01) {
340
+ return this.createSaltireSparkle(canvasWidth, canvasHeight, config);
341
+ }
342
+
343
+ return null;
344
+ },
345
+
346
+ /**
347
+ * Draw heather petal (simple purple bell-shaped petal)
348
+ */
349
+ drawHeatherPetal(ctx, particle, time) {
350
+ const x = particle.x;
351
+ const y = particle.y;
352
+ const size = particle.size;
353
+
354
+ // Flutter effect (petal curling as it drifts)
355
+ const flutter = Math.sin(time * particle.flutterSpeed + particle.flutter) * 0.3;
356
+
357
+ ctx.save();
358
+ ctx.globalAlpha = particle.opacity;
359
+ ctx.translate(x, y);
360
+ ctx.rotate(particle.rotation + flutter);
361
+
362
+ // Heather bell petal shape (small bell)
363
+ ctx.fillStyle = particle.color;
364
+ ctx.strokeStyle = '#6A0DAD'; // Purple edge
365
+ ctx.lineWidth = size * 0.08;
366
+
367
+ ctx.beginPath();
368
+ // Bell shape with slight flare at bottom
369
+ ctx.moveTo(0, -size * 0.6);
370
+ ctx.bezierCurveTo(
371
+ size * 0.4, -size * 0.5,
372
+ size * 0.5, size * 0.2,
373
+ size * 0.3, size * 0.8
374
+ );
375
+ ctx.lineTo(-size * 0.3, size * 0.8);
376
+ ctx.bezierCurveTo(
377
+ -size * 0.5, size * 0.2,
378
+ -size * 0.4, -size * 0.5,
379
+ 0, -size * 0.6
380
+ );
381
+ ctx.closePath();
382
+ ctx.fill();
383
+ ctx.stroke();
384
+
385
+ // Add bell texture lines
386
+ ctx.strokeStyle = 'rgba(106, 13, 173, 0.3)';
387
+ ctx.lineWidth = size * 0.04;
388
+ for (let i = 0; i < 4; i++) {
389
+ const yPos = -size * 0.5 + i * size * 0.35;
390
+ ctx.beginPath();
391
+ ctx.moveTo(-size * 0.25, yPos);
392
+ ctx.lineTo(size * 0.25, yPos);
393
+ ctx.stroke();
394
+ }
395
+
396
+ ctx.restore();
397
+ },
398
+
399
+ /**
400
+ * Draw heather (Scottish Highland heather sprig with bell-shaped flowers)
401
+ */
402
+ drawHeather(ctx, particle) {
403
+ const x = particle.x;
404
+ const y = particle.y;
405
+ const size = particle.size;
406
+ const sway = Math.sin(particle.sway) * size * 0.4;
407
+
408
+ ctx.save();
409
+ ctx.globalAlpha = particle.opacity;
410
+ ctx.translate(x + sway, y);
411
+ ctx.rotate(particle.rotation);
412
+
413
+ // Stem (thin green-brown)
414
+ ctx.strokeStyle = '#6b5c4d';
415
+ ctx.lineWidth = size * 0.12;
416
+ ctx.lineCap = 'round';
417
+ ctx.beginPath();
418
+ ctx.moveTo(0, size * 0.6);
419
+ ctx.lineTo(0, -size * 0.8);
420
+ ctx.stroke();
421
+
422
+ // Bell-shaped flowers along the stem (small purple bells)
423
+ const bellCount = particle.bellCount || 4;
424
+ for (let i = 0; i < bellCount; i++) {
425
+ const yPos = -size * 0.7 + (i / bellCount) * size * 1.2;
426
+ const bellSize = size * (0.18 + Math.random() * 0.08);
427
+ const side = i % 2 === 0 ? 1 : -1; // Alternate sides
428
+
429
+ ctx.save();
430
+ ctx.translate(side * size * 0.15, yPos);
431
+ ctx.rotate(side * 0.3);
432
+
433
+ // Bell flower (small oval with darker top)
434
+ const bellGradient = ctx.createLinearGradient(0, -bellSize, 0, bellSize * 0.3);
435
+ bellGradient.addColorStop(0, particle.color);
436
+ bellGradient.addColorStop(0.6, particle.color);
437
+ bellGradient.addColorStop(1, `${particle.color}CC`); // Slightly darker at bottom
438
+ ctx.fillStyle = bellGradient;
439
+
440
+ // Bell shape (elongated oval, wider at bottom)
441
+ ctx.beginPath();
442
+ ctx.ellipse(0, 0, bellSize * 0.4, bellSize, 0, 0, Math.PI * 2);
443
+ ctx.fill();
444
+
445
+ // Bell opening (darker rim)
446
+ ctx.strokeStyle = `${particle.color}AA`;
447
+ ctx.lineWidth = bellSize * 0.1;
448
+ ctx.beginPath();
449
+ ctx.ellipse(0, bellSize * 0.7, bellSize * 0.35, bellSize * 0.15, 0, 0, Math.PI * 2);
450
+ ctx.stroke();
451
+
452
+ // Small stamen (thin line protruding from bell)
453
+ ctx.strokeStyle = '#8B7355';
454
+ ctx.lineWidth = bellSize * 0.08;
455
+ ctx.beginPath();
456
+ ctx.moveTo(0, bellSize * 0.8);
457
+ ctx.lineTo(0, bellSize * 1.1);
458
+ ctx.stroke();
459
+
460
+ ctx.restore();
461
+ }
462
+
463
+ // Small leaves at base (green-grey)
464
+ ctx.fillStyle = '#6b8b6b';
465
+ for (let i = 0; i < 3; i++) {
466
+ const angle = (i / 3) * Math.PI * 2 - Math.PI / 2;
467
+ ctx.save();
468
+ ctx.rotate(angle);
469
+ ctx.beginPath();
470
+ ctx.ellipse(0, size * 0.5, size * 0.1, size * 0.25, 0, 0, Math.PI * 2);
471
+ ctx.fill();
472
+ ctx.restore();
473
+ }
474
+
475
+ ctx.restore();
476
+ },
477
+
478
+ /**
479
+ * Draw thistle (Scottish thistle with realistic spiky bracts)
480
+ */
481
+ drawThistle(ctx, particle) {
482
+ const x = particle.x;
483
+ const y = particle.y;
484
+ const size = particle.size;
485
+
486
+ ctx.save();
487
+ ctx.globalAlpha = particle.opacity;
488
+ ctx.translate(x, y);
489
+ ctx.rotate(particle.rotation);
490
+
491
+ // Stem (woody, slightly curved)
492
+ ctx.strokeStyle = '#5a704a';
493
+ ctx.lineWidth = size * 0.1;
494
+ ctx.lineCap = 'round';
495
+ ctx.beginPath();
496
+ ctx.moveTo(0, size * 2);
497
+ ctx.quadraticCurveTo(size * 0.1, size * 1.2, 0, size * 0.5);
498
+ ctx.stroke();
499
+
500
+ // Spiky bracts (green spiny base around flower head)
501
+ const bractCount = 16;
502
+ ctx.strokeStyle = '#4a7a3a';
503
+ ctx.fillStyle = '#5a8a4a';
504
+ ctx.lineWidth = size * 0.06;
505
+ ctx.lineCap = 'round';
506
+
507
+ for (let i = 0; i < bractCount; i++) {
508
+ const angle = (i / bractCount) * Math.PI * 2;
509
+ const bractLength = size * (0.35 + Math.random() * 0.15);
510
+ const bendAngle = angle + (Math.random() - 0.5) * 0.3;
511
+
512
+ // Bract spine (sharp and pointed)
513
+ ctx.save();
514
+ ctx.rotate(angle);
515
+ ctx.beginPath();
516
+ ctx.moveTo(0, -size * 0.2);
517
+
518
+ // Curved spiky bract
519
+ const controlX = size * 0.15;
520
+ const controlY = -size * 0.3;
521
+ const endX = Math.sin(0.4) * bractLength;
522
+ const endY = -Math.cos(0.4) * bractLength - size * 0.2;
523
+
524
+ ctx.quadraticCurveTo(controlX, controlY, endX, endY);
525
+ ctx.stroke();
526
+
527
+ // Tiny spines along bract edge
528
+ for (let j = 0; j < 3; j++) {
529
+ const t = (j + 1) / 4;
530
+ const spineX = t * endX;
531
+ const spineY = -size * 0.2 + t * (endY + size * 0.2);
532
+ ctx.beginPath();
533
+ ctx.moveTo(spineX, spineY);
534
+ ctx.lineTo(spineX + size * 0.08, spineY - size * 0.06);
535
+ ctx.stroke();
536
+ }
537
+
538
+ ctx.restore();
539
+ }
540
+
541
+ // Flower head base (bulbous receptacle)
542
+ ctx.fillStyle = '#6a8a5a';
543
+ ctx.beginPath();
544
+ ctx.ellipse(0, -size * 0.15, size * 0.28, size * 0.35, 0, 0, Math.PI * 2);
545
+ ctx.fill();
546
+
547
+ // Flower petals (white or blue - Scottish Saltire colors)
548
+ const petalLayers = 3;
549
+ const petalColors = particle.petalColors || ['#9b59b6', '#b980d1', '#d4a5e3']; // Fallback to purple
550
+
551
+ for (let layer = 0; layer < petalLayers; layer++) {
552
+ const layerRadius = size * (0.25 + layer * 0.08);
553
+ const petalCount = 8 + layer * 4;
554
+ const layerColor = petalColors[layer];
555
+
556
+ ctx.fillStyle = layerColor;
557
+
558
+ for (let i = 0; i < petalCount; i++) {
559
+ const angle = (i / petalCount) * Math.PI * 2 + layer * 0.15;
560
+ const petalX = Math.cos(angle) * layerRadius;
561
+ const petalY = Math.sin(angle) * layerRadius - size * 0.15;
562
+
563
+ // Thin wispy petal
564
+ ctx.save();
565
+ ctx.translate(petalX, petalY);
566
+ ctx.rotate(angle);
567
+ ctx.beginPath();
568
+ ctx.moveTo(0, 0);
569
+ ctx.lineTo(0, -size * (0.2 + layer * 0.05));
570
+ ctx.lineWidth = size * 0.02;
571
+ ctx.strokeStyle = layerColor;
572
+ ctx.stroke();
573
+ ctx.restore();
574
+ }
575
+ }
576
+
577
+ // Serrated leaves (2-3 along stem with spiny edges)
578
+ ctx.fillStyle = '#4a6a3a';
579
+ ctx.strokeStyle = '#3a5a2a';
580
+ ctx.lineWidth = 1;
581
+
582
+ for (let i = 0; i < 2; i++) {
583
+ const leafY = size * (0.8 + i * 0.6);
584
+ const leafSide = i % 2 === 0 ? 1 : -1;
585
+
586
+ ctx.save();
587
+ ctx.translate(0, leafY);
588
+
589
+ // Leaf base shape with deep serrations
590
+ ctx.beginPath();
591
+ ctx.moveTo(0, 0);
592
+
593
+ // Create deeply lobed, spiny leaf edge
594
+ const lobes = 5;
595
+ const depth = size * 0.15; // Moved outside loop so it's accessible below
596
+
597
+ for (let j = 0; j <= lobes; j++) {
598
+ const t = j / lobes;
599
+ const leafWidth = size * 0.25 * Math.sin(t * Math.PI);
600
+
601
+ if (j < lobes) {
602
+ // Deep cut between lobes
603
+ ctx.lineTo(leafSide * leafWidth * 0.3, -depth * t);
604
+ // Sharp spine point
605
+ ctx.lineTo(leafSide * (leafWidth + size * 0.08), -depth * (t + 0.05));
606
+ ctx.lineTo(leafSide * leafWidth * 0.7, -depth * (t + 0.1));
607
+ } else {
608
+ ctx.lineTo(0, -depth);
609
+ }
610
+ }
611
+
612
+ // Return path (smooth inner edge)
613
+ ctx.quadraticCurveTo(leafSide * size * 0.05, -depth * 0.5, 0, 0);
614
+ ctx.closePath();
615
+ ctx.fill();
616
+ ctx.stroke();
617
+
618
+ // Leaf vein
619
+ ctx.strokeStyle = '#2a4a1a';
620
+ ctx.lineWidth = 0.5;
621
+ ctx.beginPath();
622
+ ctx.moveTo(0, 0);
623
+ ctx.lineTo(0, -depth);
624
+ ctx.stroke();
625
+
626
+ ctx.restore();
627
+ }
628
+
629
+ ctx.restore();
630
+ },
631
+
632
+ /**
633
+ * Draw thistle plant (ground decoration)
634
+ */
635
+ drawThistlePlant(ctx, particle, time) {
636
+ const x = particle.x;
637
+ const y = particle.y;
638
+ const size = particle.size;
639
+
640
+ // Gentle sway
641
+ const sway = Math.sin(time * 0.001 + particle.swayPhase) * 0.1;
642
+
643
+ ctx.save();
644
+ ctx.globalAlpha = particle.opacity;
645
+ ctx.translate(x, y);
646
+ ctx.rotate(sway);
647
+
648
+ // Multiple stems
649
+ for (let stem = 0; stem < 3; stem++) {
650
+ const stemX = (stem - 1) * size * 0.4;
651
+ const stemHeight = size * (1.5 + stem * 0.3);
652
+
653
+ // Stem
654
+ ctx.strokeStyle = '#8B4513';
655
+ ctx.lineWidth = size * 0.1;
656
+ ctx.beginPath();
657
+ ctx.moveTo(stemX, 0);
658
+ ctx.quadraticCurveTo(stemX + sway * size, -stemHeight * 0.5, stemX, -stemHeight);
659
+ ctx.stroke();
660
+
661
+ // Flower head
662
+ ctx.fillStyle = '#8B008B';
663
+ ctx.beginPath();
664
+ ctx.arc(stemX, -stemHeight, size * 0.4, 0, Math.PI * 2);
665
+ ctx.fill();
666
+
667
+ // Spikes
668
+ const spikeCount = 8;
669
+ ctx.strokeStyle = '#8B008B';
670
+ ctx.lineWidth = size * 0.05;
671
+ for (let i = 0; i < spikeCount; i++) {
672
+ const angle = (i / spikeCount) * Math.PI * 2;
673
+ ctx.beginPath();
674
+ ctx.moveTo(stemX, -stemHeight);
675
+ ctx.lineTo(
676
+ stemX + Math.cos(angle) * size * 0.6,
677
+ -stemHeight + Math.sin(angle) * size * 0.6
678
+ );
679
+ ctx.stroke();
680
+ }
681
+ }
682
+
683
+ ctx.restore();
684
+ },
685
+
686
+ /**
687
+ * Draw bagpiper
688
+ */
689
+ drawBagpiper(ctx, particle, time) {
690
+ const x = particle.x;
691
+ const y = particle.y;
692
+ const size = particle.size;
693
+ const dir = particle.vx > 0 ? 1 : -1;
694
+
695
+ ctx.save();
696
+ ctx.translate(x, y);
697
+ if (dir === -1) {
698
+ ctx.scale(-1, 1);
699
+ }
700
+
701
+ const marchPhase = Math.sin(time * 0.015 + particle.marchPhase) * (Math.PI / 6);
702
+
703
+ // Legs (marching)
704
+ ctx.strokeStyle = '#000080'; // Navy blue kilt
705
+ ctx.lineWidth = size * 0.15;
706
+ ctx.beginPath();
707
+ ctx.moveTo(-size * 0.2, size * 0.6);
708
+ ctx.lineTo(-size * 0.3, size * 1.4 + Math.sin(marchPhase) * size * 0.15);
709
+ ctx.stroke();
710
+ ctx.beginPath();
711
+ ctx.moveTo(size * 0.2, size * 0.6);
712
+ ctx.lineTo(size * 0.3, size * 1.4 + Math.sin(marchPhase + Math.PI) * size * 0.15);
713
+ ctx.stroke();
714
+
715
+ // Kilt (tartan pattern suggested)
716
+ ctx.fillStyle = '#000080';
717
+ ctx.fillRect(-size * 0.45, size * 0.2, size * 0.9, size * 0.5);
718
+
719
+ // Sporran (decorative pouch)
720
+ ctx.fillStyle = '#8B4513';
721
+ ctx.fillRect(-size * 0.25, size * 0.5, size * 0.5, size * 0.25);
722
+
723
+ // Body (red tunic)
724
+ ctx.fillStyle = '#8B0000';
725
+ ctx.fillRect(-size * 0.4, -size * 0.2, size * 0.8, size * 0.5);
726
+
727
+ // Arms (holding bagpipes)
728
+ ctx.strokeStyle = '#FFD7BA';
729
+ ctx.lineWidth = size * 0.12;
730
+ // Left arm
731
+ ctx.beginPath();
732
+ ctx.moveTo(-size * 0.3, 0);
733
+ ctx.lineTo(-size * 0.6, size * 0.3);
734
+ ctx.stroke();
735
+ // Right arm
736
+ ctx.beginPath();
737
+ ctx.moveTo(size * 0.3, 0);
738
+ ctx.lineTo(size * 0.7, size * 0.2);
739
+ ctx.stroke();
740
+
741
+ // Bagpipes
742
+ ctx.fillStyle = '#654321';
743
+ // Bag
744
+ ctx.beginPath();
745
+ ctx.ellipse(size * 0.1, size * 0.25, size * 0.35, size * 0.25, 0, 0, Math.PI * 2);
746
+ ctx.fill();
747
+ // Pipes (drones)
748
+ ctx.strokeStyle = '#654321';
749
+ ctx.lineWidth = size * 0.08;
750
+ ctx.beginPath();
751
+ ctx.moveTo(size * 0.15, size * 0.05);
752
+ ctx.lineTo(size * 0.15, -size * 0.7);
753
+ ctx.stroke();
754
+ ctx.beginPath();
755
+ ctx.moveTo(size * 0.3, size * 0.1);
756
+ ctx.lineTo(size * 0.3, -size * 0.6);
757
+ ctx.stroke();
758
+ // Chanter (melody pipe)
759
+ ctx.beginPath();
760
+ ctx.moveTo(size * 0.55, size * 0.2);
761
+ ctx.lineTo(size * 0.65, size * 0.7);
762
+ ctx.stroke();
763
+
764
+ // Head
765
+ ctx.fillStyle = '#FFD7BA';
766
+ ctx.beginPath();
767
+ ctx.arc(0, -size * 0.45, size * 0.35, 0, Math.PI * 2);
768
+ ctx.fill();
769
+
770
+ // Glengarry (Scottish cap)
771
+ ctx.fillStyle = '#000080';
772
+ ctx.fillRect(-size * 0.4, -size * 0.8, size * 0.8, size * 0.15);
773
+ ctx.beginPath();
774
+ ctx.moveTo(-size * 0.35, -size * 0.8);
775
+ ctx.lineTo(0, -size * 1.1);
776
+ ctx.lineTo(size * 0.35, -size * 0.8);
777
+ ctx.closePath();
778
+ ctx.fill();
779
+
780
+ // Feather plume
781
+ ctx.strokeStyle = '#ff0000';
782
+ ctx.lineWidth = 2;
783
+ ctx.beginPath();
784
+ ctx.moveTo(0, -size * 1.1);
785
+ ctx.quadraticCurveTo(size * 0.2, -size * 1.3, size * 0.3, -size * 1.4);
786
+ ctx.stroke();
787
+
788
+ ctx.restore();
789
+ },
790
+
791
+ /**
792
+ * Draw Saltire flag (white X on blue)
793
+ */
794
+ drawSaltireFlag(ctx, particle, time) {
795
+ const x = particle.x;
796
+ const y = particle.y;
797
+ const size = particle.size;
798
+
799
+ // Wave effect
800
+ const wavePhase = time * 0.002 + particle.waveOffset;
801
+
802
+ ctx.save();
803
+ ctx.globalAlpha = particle.opacity;
804
+ ctx.translate(x, y);
805
+
806
+ // Flagpole
807
+ ctx.fillStyle = '#654321';
808
+ ctx.fillRect(-size * 0.05, 0, size * 0.1, size * 1.5);
809
+
810
+ // Flag (waving)
811
+ const segments = 10;
812
+ const flagWidth = size * 1.2;
813
+ const flagHeight = size * 0.8;
814
+
815
+ ctx.fillStyle = '#0065BD'; // Scottish blue
816
+
817
+ for (let i = 0; i < segments; i++) {
818
+ const segmentWidth = flagWidth / segments;
819
+ const x1 = i * segmentWidth;
820
+ const x2 = (i + 1) * segmentWidth;
821
+ const wave1 = Math.sin(wavePhase + i * 0.3) * size * 0.1;
822
+ const wave2 = Math.sin(wavePhase + (i + 1) * 0.3) * size * 0.1;
823
+
824
+ ctx.beginPath();
825
+ ctx.moveTo(x1, wave1);
826
+ ctx.lineTo(x2, wave2);
827
+ ctx.lineTo(x2, flagHeight + wave2);
828
+ ctx.lineTo(x1, flagHeight + wave1);
829
+ ctx.closePath();
830
+ ctx.fill();
831
+ }
832
+
833
+ // White X (Saltire)
834
+ ctx.strokeStyle = '#FFFFFF';
835
+ ctx.lineWidth = size * 0.12;
836
+ ctx.shadowColor = 'rgba(255, 255, 255, 0.5)';
837
+ ctx.shadowBlur = 5;
838
+
839
+ // Draw X with wave
840
+ for (let i = 0; i < segments; i++) {
841
+ const segmentWidth = flagWidth / segments;
842
+ const x1 = i * segmentWidth;
843
+ const x2 = (i + 1) * segmentWidth;
844
+ const wave1 = Math.sin(wavePhase + i * 0.3) * size * 0.1;
845
+ const wave2 = Math.sin(wavePhase + (i + 1) * 0.3) * size * 0.1;
846
+
847
+ // Top-left to bottom-right
848
+ ctx.beginPath();
849
+ ctx.moveTo(x1, wave1);
850
+ ctx.lineTo(x2, flagHeight + wave2);
851
+ ctx.stroke();
852
+
853
+ // Bottom-left to top-right
854
+ ctx.beginPath();
855
+ ctx.moveTo(x1, flagHeight + wave1);
856
+ ctx.lineTo(x2, wave2);
857
+ ctx.stroke();
858
+ }
859
+
860
+ ctx.shadowBlur = 0;
861
+ ctx.restore();
862
+ },
863
+
864
+ /**
865
+ * Draw saltire sparkle
866
+ */
867
+ drawSaltireSparkle(ctx, particle, time) {
868
+ const x = particle.x;
869
+ const y = particle.y;
870
+ const size = particle.size;
871
+ const twinkle = 0.6 + Math.sin(time * 0.004 + particle.twinklePhase) * 0.4;
872
+
873
+ ctx.save();
874
+ ctx.globalAlpha = particle.opacity * twinkle;
875
+ ctx.translate(x, y);
876
+ ctx.rotate(particle.rotation);
877
+
878
+ // Draw 4-pointed sparkle (Scottish blue or white)
879
+ ctx.fillStyle = particle.color;
880
+ ctx.shadowColor = particle.color;
881
+ ctx.shadowBlur = size * 2;
882
+
883
+ ctx.beginPath();
884
+ ctx.moveTo(0, -size);
885
+ ctx.lineTo(size * 0.3, -size * 0.3);
886
+ ctx.lineTo(size, 0);
887
+ ctx.lineTo(size * 0.3, size * 0.3);
888
+ ctx.lineTo(0, size);
889
+ ctx.lineTo(-size * 0.3, size * 0.3);
890
+ ctx.lineTo(-size, 0);
891
+ ctx.lineTo(-size * 0.3, -size * 0.3);
892
+ ctx.closePath();
893
+ ctx.fill();
894
+
895
+ ctx.restore();
896
+ },
897
+
898
+ /**
899
+ * Draw authentic Scottish tartan pattern (Stewart/Royal Stewart style)
900
+ */
901
+ drawTartanPattern(ctx, particle) {
902
+ const x = particle.x;
903
+ const y = particle.y;
904
+ const size = particle.size;
905
+
906
+ ctx.save();
907
+ ctx.globalAlpha = particle.opacity;
908
+ ctx.translate(x, y);
909
+
910
+ // Create authentic tartan weave pattern
911
+ const patternSize = 120; // Smaller repeating unit
912
+ const patternCanvas = document.createElement('canvas');
913
+ patternCanvas.width = patternSize;
914
+ patternCanvas.height = patternSize;
915
+ const pCtx = patternCanvas.getContext('2d');
916
+
917
+ // Royal Stewart tartan colors
918
+ const NAVY = '#1a237e';
919
+ const RED = '#c62828';
920
+ const FOREST = '#1b5e20';
921
+ const YELLOW = '#f9a825';
922
+ const WHITE = '#ffffff';
923
+ const BLACK = '#000000';
924
+
925
+ // Tartan stripe sequence (width, color) - authentic Royal Stewart pattern
926
+ const stripeSequence = [
927
+ [4, RED], [2, BLACK], [2, RED], [2, BLACK], [4, RED],
928
+ [8, NAVY], [2, BLACK], [2, NAVY], [2, BLACK], [8, NAVY],
929
+ [2, FOREST], [2, YELLOW], [2, FOREST],
930
+ [8, NAVY], [2, BLACK], [2, NAVY], [2, BLACK], [8, NAVY],
931
+ [4, RED], [2, BLACK], [2, RED], [2, BLACK], [4, RED]
932
+ ];
933
+
934
+ // Draw horizontal stripes
935
+ let ypos = 0;
936
+ for (let i = 0; i < 2; i++) { // Repeat pattern twice
937
+ for (const [width, color] of stripeSequence) {
938
+ pCtx.fillStyle = color;
939
+ pCtx.fillRect(0, ypos, patternSize, width);
940
+ ypos += width;
941
+ }
942
+ }
943
+
944
+ // Draw vertical stripes with blending (creates the weave effect)
945
+ pCtx.globalCompositeOperation = 'multiply';
946
+ pCtx.globalAlpha = 0.8;
947
+
948
+ let xpos = 0;
949
+ for (let i = 0; i < 2; i++) { // Repeat pattern twice
950
+ for (const [width, color] of stripeSequence) {
951
+ pCtx.fillStyle = color;
952
+ pCtx.fillRect(xpos, 0, width, patternSize);
953
+ xpos += width;
954
+ }
955
+ }
956
+
957
+ // Add white highlights (creates tartan shine)
958
+ pCtx.globalCompositeOperation = 'lighten';
959
+ pCtx.globalAlpha = 0.15;
960
+ xpos = 0;
961
+ for (let i = 0; i < 2; i++) {
962
+ for (const [width, color] of stripeSequence) {
963
+ if (color === YELLOW || color === WHITE) {
964
+ pCtx.fillStyle = WHITE;
965
+ pCtx.fillRect(xpos, 0, width, patternSize);
966
+ }
967
+ xpos += width;
968
+ }
969
+ }
970
+
971
+ // Reset composite operation
972
+ pCtx.globalCompositeOperation = 'source-over';
973
+ pCtx.globalAlpha = 1;
974
+
975
+ // Draw the tartan pattern (tiled)
976
+ const pattern = ctx.createPattern(patternCanvas, 'repeat');
977
+ ctx.fillStyle = pattern;
978
+ ctx.fillRect(-size / 2, -size / 2, size, size);
979
+
980
+ // Fabric edge (darker border to show it's fabric)
981
+ ctx.strokeStyle = '#2a2a2a';
982
+ ctx.lineWidth = size * 0.03;
983
+ ctx.strokeRect(-size / 2, -size / 2, size, size);
984
+
985
+ // Inner decorative border (silver/white tartan trim)
986
+ ctx.strokeStyle = '#e0e0e0';
987
+ ctx.lineWidth = size * 0.015;
988
+ ctx.strokeRect(-size / 2 + size * 0.04, -size / 2 + size * 0.04,
989
+ size - size * 0.08, size - size * 0.08);
990
+
991
+ // Fabric texture overlay (subtle diagonal lines)
992
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
993
+ ctx.lineWidth = 0.5;
994
+ for (let i = -size; i < size; i += 4) {
995
+ ctx.beginPath();
996
+ ctx.moveTo(-size / 2 + i, -size / 2);
997
+ ctx.lineTo(-size / 2 + i + size, size / 2);
998
+ ctx.stroke();
999
+ }
1000
+
1001
+ // Silver thistle brooch in corner (holds the tartan)
1002
+ ctx.save();
1003
+ ctx.translate(-size * 0.35, -size * 0.35);
1004
+
1005
+ // Brooch circle (silver)
1006
+ ctx.fillStyle = '#c0c0c0';
1007
+ ctx.strokeStyle = '#808080';
1008
+ ctx.lineWidth = 2;
1009
+ ctx.beginPath();
1010
+ ctx.arc(0, 0, size * 0.08, 0, Math.PI * 2);
1011
+ ctx.fill();
1012
+ ctx.stroke();
1013
+
1014
+ // Thistle emblem on brooch
1015
+ ctx.scale(0.06, 0.06);
1016
+ ctx.fillStyle = '#8B008B';
1017
+ ctx.strokeStyle = '#6A006A';
1018
+ ctx.lineWidth = 2;
1019
+
1020
+ // Stylized thistle flower
1021
+ for (let i = 0; i < 6; i++) {
1022
+ const angle = (i / 6) * Math.PI * 2;
1023
+ ctx.beginPath();
1024
+ ctx.moveTo(0, 0);
1025
+ ctx.lineTo(Math.cos(angle) * size * 0.4, Math.sin(angle) * size * 0.4);
1026
+ ctx.stroke();
1027
+ }
1028
+
1029
+ // Center dot
1030
+ ctx.beginPath();
1031
+ ctx.arc(0, 0, size * 0.15, 0, Math.PI * 2);
1032
+ ctx.fill();
1033
+
1034
+ ctx.restore();
1035
+
1036
+ ctx.restore();
1037
+ },
1038
+
1039
+ /**
1040
+ * Draw Highland scene silhouette (mountains, castle, loch)
1041
+ */
1042
+ drawHighlandScene(ctx, particle) {
1043
+ const x = particle.x;
1044
+ const y = particle.y;
1045
+ const size = particle.size;
1046
+
1047
+ ctx.save();
1048
+ ctx.globalAlpha = particle.opacity;
1049
+ ctx.translate(x, y);
1050
+
1051
+ // Sky gradient (misty blue)
1052
+ const skyGradient = ctx.createLinearGradient(0, -size, 0, size * 0.5);
1053
+ skyGradient.addColorStop(0, '#4a7ba7');
1054
+ skyGradient.addColorStop(1, '#b0c4de');
1055
+ ctx.fillStyle = skyGradient;
1056
+ ctx.fillRect(-size, -size, size * 2, size * 1.5);
1057
+
1058
+ // Distant mountains (layered)
1059
+ ctx.fillStyle = 'rgba(80, 100, 130, 0.6)';
1060
+ ctx.beginPath();
1061
+ ctx.moveTo(-size, size * 0.3);
1062
+ ctx.lineTo(-size * 0.6, -size * 0.3);
1063
+ ctx.lineTo(-size * 0.2, size * 0.1);
1064
+ ctx.lineTo(size * 0.2, -size * 0.5);
1065
+ ctx.lineTo(size * 0.6, size * 0.2);
1066
+ ctx.lineTo(size, size * 0.3);
1067
+ ctx.closePath();
1068
+ ctx.fill();
1069
+
1070
+ // Closer mountains (darker)
1071
+ ctx.fillStyle = 'rgba(60, 80, 100, 0.8)';
1072
+ ctx.beginPath();
1073
+ ctx.moveTo(-size, size * 0.4);
1074
+ ctx.lineTo(-size * 0.7, size * 0.1);
1075
+ ctx.lineTo(-size * 0.3, -size * 0.1);
1076
+ ctx.lineTo(size * 0.1, -size * 0.2);
1077
+ ctx.lineTo(size * 0.5, size * 0.15);
1078
+ ctx.lineTo(size, size * 0.4);
1079
+ ctx.closePath();
1080
+ ctx.fill();
1081
+
1082
+ // Highland castle silhouette (on hill)
1083
+ ctx.fillStyle = '#2a3a4a';
1084
+
1085
+ // Castle base
1086
+ ctx.fillRect(-size * 0.15, size * 0.25, size * 0.3, size * 0.2);
1087
+
1088
+ // Castle tower (left)
1089
+ ctx.fillRect(-size * 0.2, size * 0.15, size * 0.1, size * 0.15);
1090
+
1091
+ // Castle tower (right)
1092
+ ctx.fillRect(size * 0.1, size * 0.15, size * 0.1, size * 0.15);
1093
+
1094
+ // Central keep (tall)
1095
+ ctx.fillRect(-size * 0.06, size * 0.05, size * 0.12, size * 0.25);
1096
+
1097
+ // Battlements
1098
+ for (let i = 0; i < 5; i++) {
1099
+ if (i % 2 === 0) {
1100
+ ctx.fillRect(-size * 0.06 + i * size * 0.06, size * 0.02, size * 0.03, size * 0.03);
1101
+ }
1102
+ }
1103
+
1104
+ // Loch (water) at bottom
1105
+ const lochGradient = ctx.createLinearGradient(0, size * 0.45, 0, size * 0.6);
1106
+ lochGradient.addColorStop(0, '#1e3a5f');
1107
+ lochGradient.addColorStop(1, '#0a1f3d');
1108
+ ctx.fillStyle = lochGradient;
1109
+ ctx.fillRect(-size, size * 0.45, size * 2, size * 0.15);
1110
+
1111
+ // Reflection in loch (simplified)
1112
+ ctx.globalAlpha = particle.opacity * 0.3;
1113
+ ctx.fillStyle = '#4a6a8a';
1114
+ ctx.fillRect(-size * 0.15, size * 0.48, size * 0.3, size * 0.08);
1115
+
1116
+ // Mist over loch
1117
+ ctx.globalAlpha = particle.opacity * 0.2;
1118
+ ctx.fillStyle = '#d0e0f0';
1119
+ ctx.fillRect(-size, size * 0.5, size * 2, size * 0.05);
1120
+
1121
+ ctx.restore();
1122
+ },
1123
+
1124
+ /**
1125
+ * Draw twinkling star
1126
+ */
1127
+ drawTwinklingStar(ctx, particle, time) {
1128
+ const x = particle.x;
1129
+ const y = particle.y;
1130
+ const size = particle.size;
1131
+
1132
+ // Calculate twinkle intensity
1133
+ const twinkleIntensity = 0.5 + Math.sin(time * particle.twinkleSpeed + particle.twinklePhase) * 0.5;
1134
+
1135
+ // Scottish blue and white color scheme
1136
+ const starColor = `rgba(200, 220, 255, ${twinkleIntensity})`;
1137
+ const glowColor = `rgba(150, 180, 255, ${twinkleIntensity * 0.4})`;
1138
+
1139
+ ctx.save();
1140
+ ctx.translate(x, y);
1141
+
1142
+ // Outer glow
1143
+ ctx.shadowColor = glowColor;
1144
+ ctx.shadowBlur = size * 3 * twinkleIntensity;
1145
+ ctx.fillStyle = starColor;
1146
+
1147
+ // Draw 4-pointed star
1148
+ ctx.beginPath();
1149
+ for (let i = 0; i < 4; i++) {
1150
+ const angle = (i * Math.PI) / 2;
1151
+ const outerX = Math.cos(angle) * size;
1152
+ const outerY = Math.sin(angle) * size;
1153
+ const innerAngle = angle + Math.PI / 4;
1154
+ const innerX = Math.cos(innerAngle) * (size * 0.3);
1155
+ const innerY = Math.sin(innerAngle) * (size * 0.3);
1156
+
1157
+ if (i === 0) {
1158
+ ctx.moveTo(outerX, outerY);
1159
+ } else {
1160
+ ctx.lineTo(outerX, outerY);
1161
+ }
1162
+ ctx.lineTo(innerX, innerY);
1163
+ }
1164
+ ctx.closePath();
1165
+ ctx.fill();
1166
+
1167
+ // Bright center
1168
+ ctx.shadowBlur = size * 2 * twinkleIntensity;
1169
+ ctx.beginPath();
1170
+ ctx.arc(0, 0, size * 0.25, 0, Math.PI * 2);
1171
+ ctx.fill();
1172
+
1173
+ ctx.restore();
1174
+ }
1175
+ };