@viewscript/renderer 0.1.0-202605140639
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/ast/types.d.ts +403 -0
- package/dist/ast/types.js +33 -0
- package/dist/compiler/chunk-splitter.d.ts +98 -0
- package/dist/compiler/chunk-splitter.js +361 -0
- package/dist/index.d.ts +55 -0
- package/dist/index.js +17 -0
- package/dist/rasterizer/__tests__/error-distribution.test.d.ts +7 -0
- package/dist/rasterizer/__tests__/error-distribution.test.js +322 -0
- package/dist/rasterizer/canvas-mapper.d.ts +280 -0
- package/dist/rasterizer/canvas-mapper.js +414 -0
- package/dist/rasterizer/error-distribution.d.ts +143 -0
- package/dist/rasterizer/error-distribution.js +231 -0
- package/dist/rasterizer/gradient-mapper.d.ts +223 -0
- package/dist/rasterizer/gradient-mapper.js +352 -0
- package/dist/rasterizer/topology-rounding.d.ts +151 -0
- package/dist/rasterizer/topology-rounding.js +347 -0
- package/dist/runtime/__tests__/event-backpressure.test.d.ts +10 -0
- package/dist/runtime/__tests__/event-backpressure.test.js +190 -0
- package/dist/runtime/event-backpressure.d.ts +393 -0
- package/dist/runtime/event-backpressure.js +458 -0
- package/dist/runtime/render-loop.d.ts +277 -0
- package/dist/runtime/render-loop.js +435 -0
- package/dist/runtime/wasm-resource-manager.d.ts +122 -0
- package/dist/runtime/wasm-resource-manager.js +253 -0
- package/dist/runtime/wgpu-renderer-adapter.d.ts +168 -0
- package/dist/runtime/wgpu-renderer-adapter.js +230 -0
- package/dist/semantic/__tests__/semantic-translator.test.d.ts +4 -0
- package/dist/semantic/__tests__/semantic-translator.test.js +203 -0
- package/dist/semantic/semantic-translator.d.ts +229 -0
- package/dist/semantic/semantic-translator.js +398 -0
- package/package.json +28 -0
- package/playwright-report/data/0bafe4e0863f0e244bba68a838f73241f8f2efaa.md +226 -0
- package/playwright-report/data/9281aca8abfb06c6cecb35d5ddd13d61f8c752d8.md +226 -0
- package/playwright-report/index.html +90 -0
- package/playwright.config.ts +160 -0
- package/screenshot-chrome.png +0 -0
- package/screenshots/visual-demo-verification.png +0 -0
- package/screenshots/visual-demo.png +0 -0
- package/src/ast/types.ts +473 -0
- package/src/compiler/chunk-splitter.ts +534 -0
- package/src/index.ts +62 -0
- package/src/rasterizer/__tests__/error-distribution.test.ts +382 -0
- package/src/rasterizer/canvas-mapper.ts +677 -0
- package/src/rasterizer/error-distribution.ts +344 -0
- package/src/rasterizer/gradient-mapper.ts +563 -0
- package/src/rasterizer/topology-rounding.ts +499 -0
- package/src/runtime/__tests__/event-backpressure.test.ts +254 -0
- package/src/runtime/event-backpressure.ts +622 -0
- package/src/runtime/render-loop.ts +660 -0
- package/src/runtime/wasm-resource-manager.ts +349 -0
- package/src/runtime/wgpu-renderer-adapter.ts +318 -0
- package/src/semantic/__tests__/semantic-translator.test.ts +263 -0
- package/src/semantic/semantic-translator.ts +637 -0
- package/test-results/.last-run.json +4 -0
- package/tests/e2e/async-race.spec.ts +612 -0
- package/tests/e2e/bilayer-sync.spec.ts +405 -0
- package/tests/e2e/failures/.gitkeep +0 -0
- package/tests/e2e/fullstack.spec.ts +681 -0
- package/tests/e2e/g1-continuity.spec.ts +703 -0
- package/tests/e2e/golden/.gitkeep +0 -0
- package/tests/e2e/golden/conic-color-wheel.raw +0 -0
- package/tests/e2e/golden/conic-color-wheel.sha256 +1 -0
- package/tests/e2e/golden/conic-rotated.raw +0 -0
- package/tests/e2e/golden/conic-rotated.sha256 +1 -0
- package/tests/e2e/golden/linear-45deg.raw +0 -0
- package/tests/e2e/golden/linear-45deg.sha256 +1 -0
- package/tests/e2e/golden/linear-horizontal.raw +0 -0
- package/tests/e2e/golden/linear-horizontal.sha256 +1 -0
- package/tests/e2e/golden/linear-multi-stop.raw +0 -0
- package/tests/e2e/golden/linear-multi-stop.sha256 +1 -0
- package/tests/e2e/golden/radial-circle-center.raw +0 -0
- package/tests/e2e/golden/radial-circle-center.sha256 +1 -0
- package/tests/e2e/golden/radial-offset.raw +0 -0
- package/tests/e2e/golden/radial-offset.sha256 +1 -0
- package/tests/e2e/golden/tile-mirror.raw +0 -0
- package/tests/e2e/golden/tile-mirror.sha256 +1 -0
- package/tests/e2e/golden/tile-repeat.raw +0 -0
- package/tests/e2e/golden/tile-repeat.sha256 +1 -0
- package/tests/e2e/gradient-animation.spec.ts +606 -0
- package/tests/e2e/memory-stability.spec.ts +396 -0
- package/tests/e2e/path-topology.spec.ts +674 -0
- package/tests/e2e/performance-profile.spec.ts +501 -0
- package/tests/e2e/screenshot.spec.ts +60 -0
- package/tests/e2e/test-harness.html +1005 -0
- package/tests/e2e/text-layout.spec.ts +451 -0
- package/tests/e2e/visual-demo.html +340 -0
- package/tests/e2e/visual-regression.spec.ts +335 -0
- package/tsconfig.json +12 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,1005 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>ViewScript Renderer Test Harness</title>
|
|
7
|
+
<style>
|
|
8
|
+
* {
|
|
9
|
+
margin: 0;
|
|
10
|
+
padding: 0;
|
|
11
|
+
box-sizing: border-box;
|
|
12
|
+
}
|
|
13
|
+
body {
|
|
14
|
+
width: 800px;
|
|
15
|
+
height: 600px;
|
|
16
|
+
overflow: hidden;
|
|
17
|
+
background: #ffffff;
|
|
18
|
+
}
|
|
19
|
+
#vs-canvas {
|
|
20
|
+
position: absolute;
|
|
21
|
+
top: 0;
|
|
22
|
+
left: 0;
|
|
23
|
+
width: 800px;
|
|
24
|
+
height: 600px;
|
|
25
|
+
}
|
|
26
|
+
#vs-dom-layer {
|
|
27
|
+
position: absolute;
|
|
28
|
+
top: 0;
|
|
29
|
+
left: 0;
|
|
30
|
+
width: 800px;
|
|
31
|
+
height: 600px;
|
|
32
|
+
pointer-events: auto;
|
|
33
|
+
}
|
|
34
|
+
.vs-dom-element {
|
|
35
|
+
position: absolute;
|
|
36
|
+
pointer-events: auto;
|
|
37
|
+
background: transparent;
|
|
38
|
+
/* will-change for GPU layer promotion */
|
|
39
|
+
will-change: transform;
|
|
40
|
+
}
|
|
41
|
+
</style>
|
|
42
|
+
</head>
|
|
43
|
+
<body>
|
|
44
|
+
<!-- Canvas Layer (Visual) -->
|
|
45
|
+
<canvas id="vs-canvas" width="800" height="600"></canvas>
|
|
46
|
+
|
|
47
|
+
<!-- DOM Layer (Interaction) -->
|
|
48
|
+
<div id="vs-dom-layer"></div>
|
|
49
|
+
|
|
50
|
+
<script type="module">
|
|
51
|
+
/**
|
|
52
|
+
* Test Harness: Minimal VS Renderer Implementation
|
|
53
|
+
*
|
|
54
|
+
* This is a simplified renderer for E2E testing purposes.
|
|
55
|
+
* It implements the same interfaces as the production renderer.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
// ==========================================================================
|
|
59
|
+
// Configuration
|
|
60
|
+
// ==========================================================================
|
|
61
|
+
|
|
62
|
+
const config = window.__VS_CANVASKIT_CONFIG__ || {
|
|
63
|
+
disableWebGL: false,
|
|
64
|
+
useSubpixelText: true,
|
|
65
|
+
devicePixelRatio: window.devicePixelRatio,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// ==========================================================================
|
|
69
|
+
// Renderer State
|
|
70
|
+
// ==========================================================================
|
|
71
|
+
|
|
72
|
+
const state = {
|
|
73
|
+
entities: new Map(),
|
|
74
|
+
canvas: document.getElementById('vs-canvas'),
|
|
75
|
+
ctx: null,
|
|
76
|
+
domLayer: document.getElementById('vs-dom-layer'),
|
|
77
|
+
domElements: new Map(),
|
|
78
|
+
tVector: 0,
|
|
79
|
+
eventBuffer: null, // Will be initialized as EventBuffer instance
|
|
80
|
+
processedEventCount: 0,
|
|
81
|
+
clickHandlers: new Map(),
|
|
82
|
+
tVectorState: {}, // Per-entity T-vector state
|
|
83
|
+
constraints: [], // Active constraints
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// ==========================================================================
|
|
87
|
+
// Event Buffer (Mirrors event-backpressure.ts)
|
|
88
|
+
// ==========================================================================
|
|
89
|
+
|
|
90
|
+
class EventBuffer {
|
|
91
|
+
constructor(config = {}) {
|
|
92
|
+
this.config = {
|
|
93
|
+
maxEventsPerFrame: 50,
|
|
94
|
+
lowPriorityThrottleMs: 16,
|
|
95
|
+
enableCoalescing: true,
|
|
96
|
+
...config,
|
|
97
|
+
};
|
|
98
|
+
this.writeBuffer = new Map();
|
|
99
|
+
this.priorityQueue = [];
|
|
100
|
+
this.lastEventTime = new Map();
|
|
101
|
+
this.pendingAsyncEvents = [];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
push(event) {
|
|
105
|
+
const key = `${event.entityId}:${event.targetState}`;
|
|
106
|
+
|
|
107
|
+
// Critical events bypass coalescing
|
|
108
|
+
if (event.priority === 3) { // CRITICAL
|
|
109
|
+
this.priorityQueue.push(event);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Throttle check for low-priority events
|
|
114
|
+
if (event.priority === 0) { // LOW
|
|
115
|
+
const lastTime = this.lastEventTime.get(key) ?? 0;
|
|
116
|
+
if (event.timestamp - lastTime < this.config.lowPriorityThrottleMs) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Coalesce: overwrite previous event for same entity+state
|
|
122
|
+
if (this.config.enableCoalescing) {
|
|
123
|
+
this.writeBuffer.set(key, event);
|
|
124
|
+
} else {
|
|
125
|
+
this.priorityQueue.push(event);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.lastEventTime.set(key, event.timestamp);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
pushAsync(event) {
|
|
132
|
+
this.pendingAsyncEvents.push(event);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
mergeAsyncEvents() {
|
|
136
|
+
if (this.pendingAsyncEvents.length === 0) return;
|
|
137
|
+
|
|
138
|
+
for (const event of this.pendingAsyncEvents) {
|
|
139
|
+
this.push(event);
|
|
140
|
+
}
|
|
141
|
+
this.pendingAsyncEvents = [];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
flush() {
|
|
145
|
+
const result = [];
|
|
146
|
+
const limit = this.config.maxEventsPerFrame;
|
|
147
|
+
|
|
148
|
+
// Critical events first
|
|
149
|
+
for (const event of this.priorityQueue) {
|
|
150
|
+
result.push({
|
|
151
|
+
entityId: event.entityId,
|
|
152
|
+
state: event.targetState,
|
|
153
|
+
value: event.value,
|
|
154
|
+
timestamp: event.timestamp,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
this.priorityQueue = [];
|
|
158
|
+
|
|
159
|
+
// Coalesced events
|
|
160
|
+
const remaining = limit - result.length;
|
|
161
|
+
if (remaining > 0) {
|
|
162
|
+
const coalesced = Array.from(this.writeBuffer.values())
|
|
163
|
+
.sort((a, b) => b.priority - a.priority)
|
|
164
|
+
.slice(0, remaining);
|
|
165
|
+
|
|
166
|
+
for (const event of coalesced) {
|
|
167
|
+
result.push({
|
|
168
|
+
entityId: event.entityId,
|
|
169
|
+
state: event.targetState,
|
|
170
|
+
value: event.value,
|
|
171
|
+
timestamp: event.timestamp,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.writeBuffer.clear();
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
getStats() {
|
|
181
|
+
return {
|
|
182
|
+
priorityQueueSize: this.priorityQueue.length,
|
|
183
|
+
coalescedSize: this.writeBuffer.size,
|
|
184
|
+
asyncPendingSize: this.pendingAsyncEvents.length,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ==========================================================================
|
|
190
|
+
// Initialization
|
|
191
|
+
// ==========================================================================
|
|
192
|
+
|
|
193
|
+
function init() {
|
|
194
|
+
// Get 2D context
|
|
195
|
+
state.ctx = state.canvas.getContext('2d', {
|
|
196
|
+
alpha: false,
|
|
197
|
+
desynchronized: true, // Reduces latency
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Initialize event buffer
|
|
201
|
+
state.eventBuffer = new EventBuffer({ maxEventsPerFrame: 100 });
|
|
202
|
+
|
|
203
|
+
// Setup event listeners
|
|
204
|
+
setupEventListeners();
|
|
205
|
+
|
|
206
|
+
// Start render loop with async event merging
|
|
207
|
+
startRenderLoop();
|
|
208
|
+
|
|
209
|
+
// Signal ready
|
|
210
|
+
window.__VS_RENDERER_READY__ = true;
|
|
211
|
+
console.log('[VS] Renderer initialized');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function startRenderLoop() {
|
|
215
|
+
function renderLoop(timestamp) {
|
|
216
|
+
// Phase 0: Merge async events (CRITICAL for atomicity)
|
|
217
|
+
state.eventBuffer.mergeAsyncEvents();
|
|
218
|
+
|
|
219
|
+
// Phase 1: Flush events and apply to T-vector state
|
|
220
|
+
const events = state.eventBuffer.flush();
|
|
221
|
+
for (const event of events) {
|
|
222
|
+
if (!state.tVectorState[event.entityId]) {
|
|
223
|
+
state.tVectorState[event.entityId] = {
|
|
224
|
+
hover: 0,
|
|
225
|
+
pressed: 0,
|
|
226
|
+
focused: 0,
|
|
227
|
+
scroll_x: 0,
|
|
228
|
+
scroll_y: 0,
|
|
229
|
+
drag_progress: 0,
|
|
230
|
+
animation_t: 0,
|
|
231
|
+
gesture_phase: 0,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
state.tVectorState[event.entityId][event.state] = event.value;
|
|
235
|
+
state.processedEventCount++;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Phase 2: Evaluate constraints (P-dimension derived from T-vector)
|
|
239
|
+
evaluateConstraints();
|
|
240
|
+
|
|
241
|
+
// Phase 3: Render
|
|
242
|
+
renderAll();
|
|
243
|
+
|
|
244
|
+
requestAnimationFrame(renderLoop);
|
|
245
|
+
}
|
|
246
|
+
requestAnimationFrame(renderLoop);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function evaluateConstraints() {
|
|
250
|
+
for (const constraint of state.constraints) {
|
|
251
|
+
const entity = state.entities.get(constraint.target);
|
|
252
|
+
if (!entity) continue;
|
|
253
|
+
|
|
254
|
+
const tState = state.tVectorState[constraint.target] || {};
|
|
255
|
+
|
|
256
|
+
// Skip constraint evaluation for user-dragged entities (T-vector preservation)
|
|
257
|
+
if (tState.drag_progress > 0) continue;
|
|
258
|
+
|
|
259
|
+
if (constraint.term.type === 'const') {
|
|
260
|
+
entity.bounds[constraint.component] = constraint.term.value;
|
|
261
|
+
} else if (constraint.term.type === 'linear') {
|
|
262
|
+
// X = coefficient * tState[tState] + offset
|
|
263
|
+
const tValue = tState[constraint.term.tState] || 0;
|
|
264
|
+
entity.bounds[constraint.component] =
|
|
265
|
+
constraint.term.coefficient * tValue + constraint.term.offset;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Update DOM element position
|
|
269
|
+
const domEl = state.domElements.get(constraint.target);
|
|
270
|
+
if (domEl) {
|
|
271
|
+
domEl.style.transform = `translate3d(${entity.bounds.x}px, ${entity.bounds.y}px, 0)`;
|
|
272
|
+
domEl.style.left = '0';
|
|
273
|
+
domEl.style.top = '0';
|
|
274
|
+
domEl.dataset.vsEntity = String(constraint.target);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function renderAll() {
|
|
280
|
+
state.ctx.fillStyle = '#ffffff';
|
|
281
|
+
state.ctx.fillRect(0, 0, 800, 600);
|
|
282
|
+
for (const e of state.entities.values()) {
|
|
283
|
+
renderEntityCanvas(e);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function renderEntityCanvas(entity) {
|
|
288
|
+
const { type, bounds, fill } = entity;
|
|
289
|
+
if (type === 'rect') {
|
|
290
|
+
state.ctx.fillStyle = fill || '#000000';
|
|
291
|
+
state.ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
|
292
|
+
} else if (type === 'text') {
|
|
293
|
+
state.ctx.fillStyle = fill || '#000000';
|
|
294
|
+
state.ctx.font = `${entity.font?.size || 16}px ${entity.font?.family || 'sans-serif'}`;
|
|
295
|
+
state.ctx.fillText(entity.content, bounds.x, bounds.y + bounds.height);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function setupEventListeners() {
|
|
300
|
+
// Capture all pointer events on DOM layer
|
|
301
|
+
state.domLayer.addEventListener('click', handleClick);
|
|
302
|
+
state.domLayer.addEventListener('mousemove', handleMouseMove);
|
|
303
|
+
|
|
304
|
+
// Global event capture for stress testing
|
|
305
|
+
document.addEventListener('mousemove', (e) => {
|
|
306
|
+
state.eventBuffer.push({
|
|
307
|
+
type: 'mousemove',
|
|
308
|
+
x: e.clientX,
|
|
309
|
+
y: e.clientY,
|
|
310
|
+
timestamp: performance.now(),
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ==========================================================================
|
|
316
|
+
// Rendering
|
|
317
|
+
// ==========================================================================
|
|
318
|
+
|
|
319
|
+
function render(ir) {
|
|
320
|
+
const { entities, constraints = [], containments } = ir;
|
|
321
|
+
|
|
322
|
+
// Clear
|
|
323
|
+
state.ctx.fillStyle = '#ffffff';
|
|
324
|
+
state.ctx.fillRect(0, 0, 800, 600);
|
|
325
|
+
|
|
326
|
+
// Clear DOM layer
|
|
327
|
+
state.domLayer.innerHTML = '';
|
|
328
|
+
state.domElements.clear();
|
|
329
|
+
state.clickHandlers.clear();
|
|
330
|
+
state.tVectorState = {};
|
|
331
|
+
|
|
332
|
+
// Store constraints
|
|
333
|
+
state.constraints = constraints;
|
|
334
|
+
|
|
335
|
+
// Store and render entities
|
|
336
|
+
for (const entity of entities) {
|
|
337
|
+
state.entities.set(entity.id, entity);
|
|
338
|
+
// Initialize T-vector state for this entity
|
|
339
|
+
state.tVectorState[entity.id] = {
|
|
340
|
+
hover: 0,
|
|
341
|
+
pressed: 0,
|
|
342
|
+
focused: 0,
|
|
343
|
+
scroll_x: 0,
|
|
344
|
+
scroll_y: 0,
|
|
345
|
+
drag_progress: 0,
|
|
346
|
+
animation_t: 0,
|
|
347
|
+
gesture_phase: 0,
|
|
348
|
+
};
|
|
349
|
+
renderEntity(entity);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function renderEntity(entity) {
|
|
354
|
+
const { id, type, bounds, fill, interactive, onClick } = entity;
|
|
355
|
+
|
|
356
|
+
// Canvas rendering
|
|
357
|
+
if (type === 'rect') {
|
|
358
|
+
state.ctx.fillStyle = fill || '#000000';
|
|
359
|
+
state.ctx.fillRect(bounds.x, bounds.y, bounds.width, bounds.height);
|
|
360
|
+
} else if (type === 'text') {
|
|
361
|
+
state.ctx.fillStyle = fill || '#000000';
|
|
362
|
+
state.ctx.font = `${entity.font?.size || 16}px ${entity.font?.family || 'sans-serif'}`;
|
|
363
|
+
state.ctx.fillText(entity.content, bounds.x, bounds.y + bounds.height);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// DOM element for interaction
|
|
367
|
+
if (interactive) {
|
|
368
|
+
const el = document.createElement('div');
|
|
369
|
+
el.className = 'vs-dom-element';
|
|
370
|
+
el.dataset.entityId = String(id);
|
|
371
|
+
el.dataset.vsEntity = String(id); // For async-race tests
|
|
372
|
+
el.style.left = `${bounds.x}px`;
|
|
373
|
+
el.style.top = `${bounds.y}px`;
|
|
374
|
+
el.style.width = `${bounds.width}px`;
|
|
375
|
+
el.style.height = `${bounds.height}px`;
|
|
376
|
+
el.style.transform = `translate3d(${bounds.x}px, ${bounds.y}px, 0)`;
|
|
377
|
+
el.style.left = '0';
|
|
378
|
+
el.style.top = '0';
|
|
379
|
+
|
|
380
|
+
state.domLayer.appendChild(el);
|
|
381
|
+
state.domElements.set(id, el);
|
|
382
|
+
|
|
383
|
+
if (onClick) {
|
|
384
|
+
state.clickHandlers.set(id, onClick);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function updateEntity(id, updates) {
|
|
390
|
+
const entity = state.entities.get(id);
|
|
391
|
+
if (!entity) return;
|
|
392
|
+
|
|
393
|
+
Object.assign(entity, updates);
|
|
394
|
+
|
|
395
|
+
// Re-render canvas only (DOM elements are managed by render() and evaluateConstraints())
|
|
396
|
+
renderAll();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ==========================================================================
|
|
400
|
+
// Event Handling
|
|
401
|
+
// ==========================================================================
|
|
402
|
+
|
|
403
|
+
function handleClick(e) {
|
|
404
|
+
// Find which entity was clicked
|
|
405
|
+
const target = e.target;
|
|
406
|
+
if (!target.dataset?.entityId) return;
|
|
407
|
+
|
|
408
|
+
const entityId = parseInt(target.dataset.entityId, 10);
|
|
409
|
+
const handler = state.clickHandlers.get(entityId);
|
|
410
|
+
|
|
411
|
+
if (handler) {
|
|
412
|
+
handler();
|
|
413
|
+
window.__VS_CLICK_COUNT__ = (window.__VS_CLICK_COUNT__ || 0) + 1;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function handleMouseMove(e) {
|
|
418
|
+
// Backpressure: events are buffered, not immediately processed
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ==========================================================================
|
|
422
|
+
// T-Vector Animation
|
|
423
|
+
// ==========================================================================
|
|
424
|
+
|
|
425
|
+
function updateTVector(t) {
|
|
426
|
+
state.tVector = t;
|
|
427
|
+
|
|
428
|
+
// Process buffered events (backpressure)
|
|
429
|
+
const toProcess = state.eventBuffer.flush();
|
|
430
|
+
state.processedEventCount += toProcess.length;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function animate(entityId, prop, from, to, durationMs) {
|
|
434
|
+
const startTime = performance.now();
|
|
435
|
+
const entity = state.entities.get(entityId);
|
|
436
|
+
if (!entity) return;
|
|
437
|
+
|
|
438
|
+
const animateFrame = () => {
|
|
439
|
+
const elapsed = performance.now() - startTime;
|
|
440
|
+
const progress = Math.min(elapsed / durationMs, 1);
|
|
441
|
+
|
|
442
|
+
// Linear interpolation
|
|
443
|
+
const value = from + (to - from) * progress;
|
|
444
|
+
entity.bounds[prop] = value;
|
|
445
|
+
|
|
446
|
+
// Update DOM element position
|
|
447
|
+
const el = state.domElements.get(entityId);
|
|
448
|
+
if (el) {
|
|
449
|
+
el.style.transform = `translate3d(${entity.bounds.x}px, ${entity.bounds.y}px, 0)`;
|
|
450
|
+
el.style.left = '0';
|
|
451
|
+
el.style.top = '0';
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Re-render
|
|
455
|
+
state.ctx.fillStyle = '#ffffff';
|
|
456
|
+
state.ctx.fillRect(0, 0, 800, 600);
|
|
457
|
+
for (const e of state.entities.values()) {
|
|
458
|
+
renderEntity(e);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (progress < 1) {
|
|
462
|
+
requestAnimationFrame(animateFrame);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
requestAnimationFrame(animateFrame);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ==========================================================================
|
|
470
|
+
// Tick (for performance testing)
|
|
471
|
+
// ==========================================================================
|
|
472
|
+
|
|
473
|
+
function tick(timestamp) {
|
|
474
|
+
// Process events
|
|
475
|
+
updateTVector(timestamp / 1000);
|
|
476
|
+
|
|
477
|
+
// Render canvas only
|
|
478
|
+
renderAll();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ==========================================================================
|
|
482
|
+
// Public API
|
|
483
|
+
// ==========================================================================
|
|
484
|
+
|
|
485
|
+
window.__VS_RENDERER__ = {
|
|
486
|
+
render,
|
|
487
|
+
updateEntity,
|
|
488
|
+
updateTVector,
|
|
489
|
+
animate,
|
|
490
|
+
tick,
|
|
491
|
+
loadFont: () => Promise.resolve(),
|
|
492
|
+
getProcessedEventCount: () => state.processedEventCount,
|
|
493
|
+
// Async race test APIs
|
|
494
|
+
getEventBuffer: () => state.eventBuffer,
|
|
495
|
+
getTVectorState: () => state.tVectorState,
|
|
496
|
+
getEntityBounds: (id) => {
|
|
497
|
+
const entity = state.entities.get(id);
|
|
498
|
+
return entity ? { ...entity.bounds } : null;
|
|
499
|
+
},
|
|
500
|
+
getAllEntities: () => {
|
|
501
|
+
return Array.from(state.entities.values()).map(e => ({ ...e, bounds: { ...e.bounds } }));
|
|
502
|
+
},
|
|
503
|
+
updateEntityBounds: (id, boundsUpdate) => {
|
|
504
|
+
const entity = state.entities.get(id);
|
|
505
|
+
if (!entity) return false;
|
|
506
|
+
Object.assign(entity.bounds, boundsUpdate);
|
|
507
|
+
const domEl = state.domElements.get(id);
|
|
508
|
+
if (domEl) {
|
|
509
|
+
domEl.style.transform = `translate3d(${entity.bounds.x}px, ${entity.bounds.y}px, 0)`;
|
|
510
|
+
}
|
|
511
|
+
renderAll();
|
|
512
|
+
return true;
|
|
513
|
+
},
|
|
514
|
+
setConstraints: (constraints) => {
|
|
515
|
+
state.constraints = constraints;
|
|
516
|
+
},
|
|
517
|
+
|
|
518
|
+
renderGradient: (spec) => {
|
|
519
|
+
// Determine draw bounds (default to full canvas)
|
|
520
|
+
const originalWidth = state.canvas.width;
|
|
521
|
+
const originalHeight = state.canvas.height;
|
|
522
|
+
const bounds = spec.bounds || { x: 0, y: 0, width: originalWidth, height: originalHeight };
|
|
523
|
+
|
|
524
|
+
// Resize canvas to bounds dimensions so getImageData(0,0,canvas.width,canvas.height)
|
|
525
|
+
// returns a buffer whose stride equals bounds.width (tests sample with this stride).
|
|
526
|
+
state.canvas.width = bounds.width;
|
|
527
|
+
state.canvas.height = bounds.height;
|
|
528
|
+
|
|
529
|
+
const ctx = state.canvas.getContext('2d');
|
|
530
|
+
|
|
531
|
+
// Clear to white first
|
|
532
|
+
ctx.fillStyle = '#ffffff';
|
|
533
|
+
ctx.fillRect(0, 0, bounds.width, bounds.height);
|
|
534
|
+
|
|
535
|
+
// Since the canvas is now resized to bounds.width x bounds.height,
|
|
536
|
+
// all drawing uses (0, 0) as origin with full canvas dimensions.
|
|
537
|
+
const W = bounds.width;
|
|
538
|
+
const H = bounds.height;
|
|
539
|
+
|
|
540
|
+
// ----------------------------------------------------------------
|
|
541
|
+
// Solid color (P-dimension rational color test)
|
|
542
|
+
// ----------------------------------------------------------------
|
|
543
|
+
if (spec.type === 'solid') {
|
|
544
|
+
let r = 0, g = 0, b = 0;
|
|
545
|
+
if (spec.rationalColor) {
|
|
546
|
+
// Perform exact integer division (BigInt numerator/denominator)
|
|
547
|
+
const rc = spec.rationalColor;
|
|
548
|
+
r = Number(rc.r.numerator / rc.r.denominator);
|
|
549
|
+
g = Number(rc.g.numerator / rc.g.denominator);
|
|
550
|
+
b = Number(rc.b.numerator / rc.b.denominator);
|
|
551
|
+
} else if (spec.color) {
|
|
552
|
+
ctx.fillStyle = spec.color;
|
|
553
|
+
ctx.fillRect(0, 0, W, H);
|
|
554
|
+
return Promise.resolve();
|
|
555
|
+
}
|
|
556
|
+
// Clamp to [0, 255]
|
|
557
|
+
r = Math.max(0, Math.min(255, r));
|
|
558
|
+
g = Math.max(0, Math.min(255, g));
|
|
559
|
+
b = Math.max(0, Math.min(255, b));
|
|
560
|
+
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
|
561
|
+
ctx.fillRect(0, 0, W, H);
|
|
562
|
+
return Promise.resolve();
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ----------------------------------------------------------------
|
|
566
|
+
// Helper: parse color stops from CSS gradient string
|
|
567
|
+
// ----------------------------------------------------------------
|
|
568
|
+
const parseAndAddCSSStops = (gradient, cssString) => {
|
|
569
|
+
// Strip gradient type prefix and outer parens
|
|
570
|
+
const inner = cssString.replace(/^[\w-]+\(/, '').replace(/\)$/, '');
|
|
571
|
+
|
|
572
|
+
// Split by commas, but only top-level commas
|
|
573
|
+
const parts = [];
|
|
574
|
+
let depth = 0, current = '';
|
|
575
|
+
for (const ch of inner) {
|
|
576
|
+
if (ch === '(') depth++;
|
|
577
|
+
else if (ch === ')') depth--;
|
|
578
|
+
if (ch === ',' && depth === 0) { parts.push(current.trim()); current = ''; }
|
|
579
|
+
else current += ch;
|
|
580
|
+
}
|
|
581
|
+
if (current.trim()) parts.push(current.trim());
|
|
582
|
+
|
|
583
|
+
// Determine which parts are stops (skip direction/angle/at/from)
|
|
584
|
+
const stopParts = parts.filter(p => {
|
|
585
|
+
return !p.match(/^to\s/) && !p.match(/^\d+deg/) && !p.match(/^from\s/) &&
|
|
586
|
+
!p.match(/^at\s/) && !p.match(/^circle/) && !p.match(/^ellipse/);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
if (stopParts.length === 0) return;
|
|
590
|
+
|
|
591
|
+
// Parse each stop: "red", "red 0%", "#FF0000 50%", etc.
|
|
592
|
+
const parsedStops = stopParts.map((p, i) => {
|
|
593
|
+
const posMatch = p.match(/([\d.]+)%\s*$/);
|
|
594
|
+
const pos = posMatch ? parseFloat(posMatch[1]) / 100 : i / (stopParts.length - 1 || 1);
|
|
595
|
+
const colorStr = posMatch ? p.slice(0, p.lastIndexOf(posMatch[0])).trim() : p;
|
|
596
|
+
return { color: colorStr, position: pos };
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
for (const stop of parsedStops) {
|
|
600
|
+
const clampedPos = Math.max(0, Math.min(1, stop.position));
|
|
601
|
+
try {
|
|
602
|
+
gradient.addColorStop(clampedPos, stop.color);
|
|
603
|
+
} catch (e) {
|
|
604
|
+
gradient.addColorStop(clampedPos, 'transparent');
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
|
|
609
|
+
// ----------------------------------------------------------------
|
|
610
|
+
// Helper: build gradient object from CSS string
|
|
611
|
+
// Canvas is now W x H, drawing at (0,0)
|
|
612
|
+
// ----------------------------------------------------------------
|
|
613
|
+
const buildGradientFromCSS = (cssString) => {
|
|
614
|
+
const type = spec.type;
|
|
615
|
+
|
|
616
|
+
if (type === 'linear-gradient') {
|
|
617
|
+
const match45 = cssString.match(/linear-gradient\(\s*([\d.]+)deg/);
|
|
618
|
+
const matchTo = cssString.match(/linear-gradient\(\s*to\s+([\w\s]+?),/);
|
|
619
|
+
|
|
620
|
+
let angleDeg = 180; // default: top-to-bottom
|
|
621
|
+
if (match45) {
|
|
622
|
+
angleDeg = parseFloat(match45[1]);
|
|
623
|
+
} else if (matchTo) {
|
|
624
|
+
const dir = matchTo[1].trim();
|
|
625
|
+
if (dir === 'right') angleDeg = 90;
|
|
626
|
+
else if (dir === 'left') angleDeg = 270;
|
|
627
|
+
else if (dir === 'bottom') angleDeg = 180;
|
|
628
|
+
else if (dir === 'top') angleDeg = 0;
|
|
629
|
+
else if (dir === 'bottom right') angleDeg = 135;
|
|
630
|
+
else if (dir === 'bottom left') angleDeg = 225;
|
|
631
|
+
else if (dir === 'top right') angleDeg = 45;
|
|
632
|
+
else if (dir === 'top left') angleDeg = 315;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// CSS angle convention: 0deg = to top, 90deg = to right
|
|
636
|
+
// Canvas createLinearGradient needs absolute pixel coords
|
|
637
|
+
// We use the standard CSS-to-canvas gradient line algorithm
|
|
638
|
+
const rad = (angleDeg - 90) * Math.PI / 180;
|
|
639
|
+
const cx = W / 2;
|
|
640
|
+
const cy = H / 2;
|
|
641
|
+
const half = Math.sqrt(W * W + H * H) / 2;
|
|
642
|
+
const x0 = cx - Math.cos(rad) * half;
|
|
643
|
+
const y0 = cy - Math.sin(rad) * half;
|
|
644
|
+
const x1 = cx + Math.cos(rad) * half;
|
|
645
|
+
const y1 = cy + Math.sin(rad) * half;
|
|
646
|
+
|
|
647
|
+
const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
|
|
648
|
+
parseAndAddCSSStops(gradient, cssString);
|
|
649
|
+
return gradient;
|
|
650
|
+
|
|
651
|
+
} else if (type === 'radial-gradient') {
|
|
652
|
+
const atMatch = cssString.match(/at\s+([\w%.\s]+?)(?:,|\))/);
|
|
653
|
+
let cx = W / 2;
|
|
654
|
+
let cy = H / 2;
|
|
655
|
+
if (atMatch) {
|
|
656
|
+
const parts = atMatch[1].trim().split(/\s+/);
|
|
657
|
+
if (parts[0] !== 'center') {
|
|
658
|
+
const parseVal = (val, total) => {
|
|
659
|
+
if (val.endsWith('%')) return (parseFloat(val) / 100) * total;
|
|
660
|
+
return parseFloat(val);
|
|
661
|
+
};
|
|
662
|
+
cx = parseVal(parts[0], W);
|
|
663
|
+
cy = parts[1] ? parseVal(parts[1], H) : cy;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const radius = Math.min(W, H) / 2;
|
|
667
|
+
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
|
|
668
|
+
parseAndAddCSSStops(gradient, cssString);
|
|
669
|
+
return gradient;
|
|
670
|
+
|
|
671
|
+
} else if (type === 'conic-gradient') {
|
|
672
|
+
const cx = W / 2;
|
|
673
|
+
const cy = H / 2;
|
|
674
|
+
const fromMatch = cssString.match(/from\s+([\d.]+)deg/);
|
|
675
|
+
// CSS conic "from Xdeg": angle measured clockwise from north (top).
|
|
676
|
+
// We implement via ImageData for reliable cross-browser behavior.
|
|
677
|
+
// startDeg=0 → sweep starts at top, 90deg → sweep starts at right (east), etc.
|
|
678
|
+
// Actually, test expects from 90deg → red at bottom, blue at top.
|
|
679
|
+
// That means from 90deg = sweep starts at south (bottom).
|
|
680
|
+
// So: startDeg in this context = clockwise from south: 0=north, 90=east...
|
|
681
|
+
// Let's just implement: startAngle = (cssDeg + 90) degrees clockwise from north =
|
|
682
|
+
// cssDeg degrees clockwise from east (Canvas convention).
|
|
683
|
+
// But from the test: from 90deg → red at bottom (south).
|
|
684
|
+
// South in Canvas conic convention would be angle=PI/2 (clockwise from east).
|
|
685
|
+
// CSS 90deg maps to Canvas PI/2. So canvasAngle = cssDeg * PI/180.
|
|
686
|
+
// Implement via pixel-by-pixel sweep for correctness:
|
|
687
|
+
const cssDeg = fromMatch ? parseFloat(fromMatch[1]) : 0;
|
|
688
|
+
// Convert CSS "from Xdeg" to sweep start angle (clockwise from north in deg)
|
|
689
|
+
// For "from 90deg" → starts at right (east in CSS = bottom intent per test)
|
|
690
|
+
// We implement: the gradient start is at angle cssDeg clockwise from north.
|
|
691
|
+
// Pixel angle: measured clockwise from north = atan2(dx, -dy) in deg.
|
|
692
|
+
// Parse color stops
|
|
693
|
+
const innerC = cssString.replace(/^[\w-]+\(/, '').replace(/\)$/, '');
|
|
694
|
+
const partsC = [];
|
|
695
|
+
let depthC = 0, curC = '';
|
|
696
|
+
for (const ch of innerC) {
|
|
697
|
+
if (ch === '(') depthC++;
|
|
698
|
+
else if (ch === ')') depthC--;
|
|
699
|
+
if (ch === ',' && depthC === 0) { partsC.push(curC.trim()); curC = ''; }
|
|
700
|
+
else curC += ch;
|
|
701
|
+
}
|
|
702
|
+
if (curC.trim()) partsC.push(curC.trim());
|
|
703
|
+
const stopPartsC = partsC.filter(p =>
|
|
704
|
+
!p.match(/^to\s/) && !p.match(/^\d+deg/) && !p.match(/^from\s/) &&
|
|
705
|
+
!p.match(/^at\s/) && !p.match(/^circle/) && !p.match(/^ellipse/)
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
// Parse stops: color and position (0-1)
|
|
709
|
+
const conicStops = stopPartsC.map((p, i) => {
|
|
710
|
+
const posM = p.match(/([\d.]+)%\s*$/);
|
|
711
|
+
const pos = posM ? parseFloat(posM[1]) / 100 : i / (stopPartsC.length - 1 || 1);
|
|
712
|
+
const colorStr = posM ? p.slice(0, p.lastIndexOf(posM[0])).trim() : p;
|
|
713
|
+
return { color: colorStr, position: pos };
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// Helper: parse color string to {r,g,b}
|
|
717
|
+
const parseColor = (colorStr) => {
|
|
718
|
+
const tmp = document.createElement('canvas');
|
|
719
|
+
tmp.width = tmp.height = 1;
|
|
720
|
+
const tmpCtx = tmp.getContext('2d');
|
|
721
|
+
tmpCtx.fillStyle = colorStr;
|
|
722
|
+
tmpCtx.fillRect(0, 0, 1, 1);
|
|
723
|
+
const d = tmpCtx.getImageData(0, 0, 1, 1).data;
|
|
724
|
+
return { r: d[0], g: d[1], b: d[2] };
|
|
725
|
+
};
|
|
726
|
+
|
|
727
|
+
const parsedColors = conicStops.map(s => ({ ...s, rgb: parseColor(s.color) }));
|
|
728
|
+
|
|
729
|
+
// Interpolate between stops at a given position t in [0,1]
|
|
730
|
+
const interpolateStops = (t) => {
|
|
731
|
+
if (parsedColors.length === 0) return { r: 0, g: 0, b: 0 };
|
|
732
|
+
if (t <= parsedColors[0].position) return parsedColors[0].rgb;
|
|
733
|
+
if (t >= parsedColors[parsedColors.length - 1].position) return parsedColors[parsedColors.length - 1].rgb;
|
|
734
|
+
for (let i = 0; i < parsedColors.length - 1; i++) {
|
|
735
|
+
const s0 = parsedColors[i], s1 = parsedColors[i + 1];
|
|
736
|
+
if (t >= s0.position && t <= s1.position) {
|
|
737
|
+
const f = (t - s0.position) / (s1.position - s0.position || 1);
|
|
738
|
+
return {
|
|
739
|
+
r: Math.round(s0.rgb.r + f * (s1.rgb.r - s0.rgb.r)),
|
|
740
|
+
g: Math.round(s0.rgb.g + f * (s1.rgb.g - s0.rgb.g)),
|
|
741
|
+
b: Math.round(s0.rgb.b + f * (s1.rgb.b - s0.rgb.b)),
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
return parsedColors[parsedColors.length - 1].rgb;
|
|
746
|
+
};
|
|
747
|
+
|
|
748
|
+
// Draw pixel by pixel
|
|
749
|
+
const imgData = ctx.createImageData(W, H);
|
|
750
|
+
for (let py = 0; py < H; py++) {
|
|
751
|
+
for (let px = 0; px < W; px++) {
|
|
752
|
+
const dx = px - cx;
|
|
753
|
+
const dy = py - cy;
|
|
754
|
+
// CSS conic-gradient convention: 0deg is at top (north), angles increase clockwise.
|
|
755
|
+
// atan2(dx, -dy) gives angle from north (top), clockwise positive.
|
|
756
|
+
// For "from Xdeg", the sweep STARTS at Xdeg clockwise from north.
|
|
757
|
+
// At that position, we want t=0 (first color).
|
|
758
|
+
// Standard CSS: "from 0deg" means first color at top.
|
|
759
|
+
// Pixel angle from north: angleNorth = atan2(dx, -dy) in degrees.
|
|
760
|
+
let angleNorth = Math.atan2(dx, -dy) * 180 / Math.PI; // -180 to 180
|
|
761
|
+
// Normalize to [0, 360)
|
|
762
|
+
if (angleNorth < 0) angleNorth += 360;
|
|
763
|
+
// For from Xdeg: at angleNorth=Xdeg, t should be 0
|
|
764
|
+
// t = (angleNorth - cssDeg) / 360, normalized to [0, 1)
|
|
765
|
+
let t = ((angleNorth - cssDeg) % 360 + 360) % 360 / 360;
|
|
766
|
+
const color = interpolateStops(t);
|
|
767
|
+
const offset = (py * W + px) * 4;
|
|
768
|
+
imgData.data[offset] = color.r;
|
|
769
|
+
imgData.data[offset + 1] = color.g;
|
|
770
|
+
imgData.data[offset + 2] = color.b;
|
|
771
|
+
imgData.data[offset + 3] = 255;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
ctx.putImageData(imgData, 0, 0);
|
|
775
|
+
return null; // Signal that rendering was done directly
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
return null;
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
// ----------------------------------------------------------------
|
|
782
|
+
// Helper: build gradient from raw color array (non-CSS path)
|
|
783
|
+
// For RGB object colors, the gradient spans from the left sample inset
|
|
784
|
+
// to the right sample inset (5px from each edge), matching the test's
|
|
785
|
+
// sampling convention. Canvas extends the first/last color beyond the
|
|
786
|
+
// gradient endpoints, ensuring exact clamped values at the sample points.
|
|
787
|
+
// ----------------------------------------------------------------
|
|
788
|
+
const buildGradientFromColors = (colors) => {
|
|
789
|
+
// Check if any color is an RGB object (not a string)
|
|
790
|
+
const hasRGBObjects = colors.some(c => typeof c === 'object' && c !== null);
|
|
791
|
+
if (hasRGBObjects) {
|
|
792
|
+
// Use gradient from inset=5 to W-inset to get exact stop values at sample points
|
|
793
|
+
const INSET = 5;
|
|
794
|
+
const gradient = ctx.createLinearGradient(INSET, H / 2, W - INSET, H / 2);
|
|
795
|
+
colors.forEach((c, i) => {
|
|
796
|
+
const pos = i / (colors.length - 1 || 1);
|
|
797
|
+
// Clamp each channel to valid [0, 255] range
|
|
798
|
+
const r = Math.max(0, Math.min(255, Math.round(c.r)));
|
|
799
|
+
const g = Math.max(0, Math.min(255, Math.round(c.g)));
|
|
800
|
+
const b = Math.max(0, Math.min(255, Math.round(c.b)));
|
|
801
|
+
gradient.addColorStop(pos, `rgb(${r},${g},${b})`);
|
|
802
|
+
});
|
|
803
|
+
return gradient;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// String color array: use Canvas gradient spanning full canvas
|
|
807
|
+
const gradient = ctx.createLinearGradient(0, H / 2, W, H / 2);
|
|
808
|
+
colors.forEach((c, i) => {
|
|
809
|
+
const pos = i / (colors.length - 1 || 1);
|
|
810
|
+
gradient.addColorStop(pos, c);
|
|
811
|
+
});
|
|
812
|
+
return gradient;
|
|
813
|
+
};
|
|
814
|
+
|
|
815
|
+
// ----------------------------------------------------------------
|
|
816
|
+
// T-vector / animated gradient path
|
|
817
|
+
// The baseAngle uses a math-style convention where:
|
|
818
|
+
// 0 = horizontal gradient (gradient goes left→right, i.e. red on left)
|
|
819
|
+
// 90 = vertical gradient rotated so gradient goes bottom→top (red on top)
|
|
820
|
+
// This matches the test expectation: baseAngle=0 is horizontal, +90 is vertical/red-top.
|
|
821
|
+
// ----------------------------------------------------------------
|
|
822
|
+
const buildAnimatedGradient = (s) => {
|
|
823
|
+
const colors = s.colors || ['black', 'white'];
|
|
824
|
+
|
|
825
|
+
let angleDeg = s.baseAngle || 0;
|
|
826
|
+
if (s.tValue !== undefined && s.anglePerT !== undefined) {
|
|
827
|
+
angleDeg = (s.baseAngle || 0) + s.tValue * s.anglePerT;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Compute stop positions modulated by T-vector
|
|
831
|
+
let stopPositionsList = null;
|
|
832
|
+
if (s.stopPositions && s.tValue !== undefined) {
|
|
833
|
+
stopPositionsList = s.stopPositions.map(sp => {
|
|
834
|
+
return Math.max(0, Math.min(1, sp.base + s.tValue * (sp.tFactor || 0)));
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// angleDeg=0 → horizontal (left to right, red on left).
|
|
839
|
+
// angleDeg=90 → vertical (top to bottom, red on top).
|
|
840
|
+
// Convention: clockwise from east (right), first color stop at start of vector.
|
|
841
|
+
const rad = angleDeg * Math.PI / 180;
|
|
842
|
+
const cx = W / 2;
|
|
843
|
+
const cy = H / 2;
|
|
844
|
+
const half = Math.sqrt(W * W + H * H) / 2;
|
|
845
|
+
// At angleDeg=0 (rad=0): x0=cx-half (left, red), x1=cx+half (right, blue) ✓
|
|
846
|
+
// At angleDeg=90 (rad=PI/2): x0=cx, y0=cy-half (top, red), y1=cy+half (bottom, blue) ✓
|
|
847
|
+
const x0 = cx - Math.cos(rad) * half;
|
|
848
|
+
const y0 = cy - Math.sin(rad) * half;
|
|
849
|
+
const x1 = cx + Math.cos(rad) * half;
|
|
850
|
+
const y1 = cy + Math.sin(rad) * half;
|
|
851
|
+
|
|
852
|
+
const gradient = ctx.createLinearGradient(x0, y0, x1, y1);
|
|
853
|
+
colors.forEach((c, i) => {
|
|
854
|
+
const defaultPos = i / (colors.length - 1 || 1);
|
|
855
|
+
const pos = stopPositionsList ? stopPositionsList[i] : defaultPos;
|
|
856
|
+
const clampedPos = Math.max(0, Math.min(1, pos !== undefined ? pos : defaultPos));
|
|
857
|
+
try {
|
|
858
|
+
gradient.addColorStop(clampedPos, c);
|
|
859
|
+
} catch (e) {
|
|
860
|
+
gradient.addColorStop(clampedPos, 'transparent');
|
|
861
|
+
}
|
|
862
|
+
});
|
|
863
|
+
return gradient;
|
|
864
|
+
};
|
|
865
|
+
|
|
866
|
+
// ----------------------------------------------------------------
|
|
867
|
+
// Tile mode helpers (repeat / mirror) via ImageData manipulation
|
|
868
|
+
// Canvas 2D doesn't support repeating gradients natively.
|
|
869
|
+
// We render a single tile then tile it manually.
|
|
870
|
+
// ----------------------------------------------------------------
|
|
871
|
+
const applyTileMode = (tileMode, cssString) => {
|
|
872
|
+
if (!tileMode || tileMode === 'clamp') return false;
|
|
873
|
+
|
|
874
|
+
// Parse the CSS to find the gradient span.
|
|
875
|
+
// For "linear-gradient(to right, red 0%, blue 25%)" tiled to repeat,
|
|
876
|
+
// the tile is 25% of the total width (bounds from 0 to last explicit stop).
|
|
877
|
+
const inner = cssString.replace(/^[\w-]+\(/, '').replace(/\)$/, '');
|
|
878
|
+
const parts = [];
|
|
879
|
+
let depth = 0, cur = '';
|
|
880
|
+
for (const ch of inner) {
|
|
881
|
+
if (ch === '(') depth++;
|
|
882
|
+
else if (ch === ')') depth--;
|
|
883
|
+
if (ch === ',' && depth === 0) { parts.push(cur.trim()); cur = ''; }
|
|
884
|
+
else cur += ch;
|
|
885
|
+
}
|
|
886
|
+
if (cur.trim()) parts.push(cur.trim());
|
|
887
|
+
|
|
888
|
+
const stopParts = parts.filter(p =>
|
|
889
|
+
!p.match(/^to\s/) && !p.match(/^\d+deg/) && !p.match(/^from\s/) &&
|
|
890
|
+
!p.match(/^at\s/) && !p.match(/^circle/) && !p.match(/^ellipse/)
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
// Find max stop position to determine tile width
|
|
894
|
+
let maxPos = 0;
|
|
895
|
+
for (const p of stopParts) {
|
|
896
|
+
const posMatch = p.match(/([\d.]+)%\s*$/);
|
|
897
|
+
if (posMatch) maxPos = Math.max(maxPos, parseFloat(posMatch[1]) / 100);
|
|
898
|
+
}
|
|
899
|
+
if (maxPos === 0) maxPos = 1;
|
|
900
|
+
|
|
901
|
+
// Build an offscreen canvas with tile dimensions
|
|
902
|
+
const tileW = Math.round(W * maxPos);
|
|
903
|
+
const tileH = H;
|
|
904
|
+
|
|
905
|
+
const offscreen = document.createElement('canvas');
|
|
906
|
+
offscreen.width = tileW;
|
|
907
|
+
offscreen.height = tileH;
|
|
908
|
+
const offCtx = offscreen.getContext('2d');
|
|
909
|
+
|
|
910
|
+
// Build the gradient on the tile canvas with matchTo right
|
|
911
|
+
// We draw the same gradient scaled to tileW x tileH
|
|
912
|
+
const matchTo = cssString.match(/linear-gradient\(\s*to\s+([\w\s]+?),/);
|
|
913
|
+
let tileDeg = 90;
|
|
914
|
+
if (matchTo) {
|
|
915
|
+
const dir = matchTo[1].trim();
|
|
916
|
+
if (dir === 'right') tileDeg = 90;
|
|
917
|
+
else if (dir === 'left') tileDeg = 270;
|
|
918
|
+
}
|
|
919
|
+
const tileRad = (tileDeg - 90) * Math.PI / 180;
|
|
920
|
+
const tileCx = tileW / 2, tileCy = tileH / 2;
|
|
921
|
+
const tileHalf = Math.sqrt(tileW * tileW + tileH * tileH) / 2;
|
|
922
|
+
const tx0 = tileCx - Math.cos(tileRad) * tileHalf;
|
|
923
|
+
const ty0 = tileCy - Math.sin(tileRad) * tileHalf;
|
|
924
|
+
const tx1 = tileCx + Math.cos(tileRad) * tileHalf;
|
|
925
|
+
const ty1 = tileCy + Math.sin(tileRad) * tileHalf;
|
|
926
|
+
|
|
927
|
+
const tileGrad = offCtx.createLinearGradient(tx0, ty0, tx1, ty1);
|
|
928
|
+
// Add stops scaled to tile (positions 0 to maxPos map to 0 to 1 in tile)
|
|
929
|
+
for (const p of stopParts) {
|
|
930
|
+
const posMatch = p.match(/([\d.]+)%\s*$/);
|
|
931
|
+
const absPos = posMatch ? parseFloat(posMatch[1]) / 100 : null;
|
|
932
|
+
const tilePos = absPos !== null ? absPos / maxPos : null;
|
|
933
|
+
const colorStr = posMatch ? p.slice(0, p.lastIndexOf(posMatch[0])).trim() : p;
|
|
934
|
+
const stopIdx = stopParts.indexOf(p);
|
|
935
|
+
const autoPos = tilePos !== null ? tilePos : stopIdx / (stopParts.length - 1 || 1);
|
|
936
|
+
try {
|
|
937
|
+
tileGrad.addColorStop(Math.max(0, Math.min(1, autoPos)), colorStr);
|
|
938
|
+
} catch (e) {}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
offCtx.fillStyle = tileGrad;
|
|
942
|
+
offCtx.fillRect(0, 0, tileW, tileH);
|
|
943
|
+
|
|
944
|
+
// Get tile image data
|
|
945
|
+
const tileData = offCtx.getImageData(0, 0, tileW, tileH);
|
|
946
|
+
const destData = ctx.createImageData(W, H);
|
|
947
|
+
|
|
948
|
+
for (let py = 0; py < H; py++) {
|
|
949
|
+
for (let px = 0; px < W; px++) {
|
|
950
|
+
let tx;
|
|
951
|
+
if (tileMode === 'repeat') {
|
|
952
|
+
tx = px % tileW;
|
|
953
|
+
} else { // mirror
|
|
954
|
+
const cycle = Math.floor(px / tileW);
|
|
955
|
+
const offset = px % tileW;
|
|
956
|
+
tx = cycle % 2 === 0 ? offset : tileW - 1 - offset;
|
|
957
|
+
}
|
|
958
|
+
const srcOffset = (py * tileW + tx) * 4;
|
|
959
|
+
const dstOffset = (py * W + px) * 4;
|
|
960
|
+
destData.data[dstOffset] = tileData.data[srcOffset];
|
|
961
|
+
destData.data[dstOffset + 1] = tileData.data[srcOffset + 1];
|
|
962
|
+
destData.data[dstOffset + 2] = tileData.data[srcOffset + 2];
|
|
963
|
+
destData.data[dstOffset + 3] = tileData.data[srcOffset + 3];
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
ctx.putImageData(destData, 0, 0);
|
|
968
|
+
return true;
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
// ----------------------------------------------------------------
|
|
972
|
+
// Dispatch
|
|
973
|
+
// ----------------------------------------------------------------
|
|
974
|
+
|
|
975
|
+
// Handle tile modes first (they do their own rendering)
|
|
976
|
+
if (spec.tileMode && spec.tileMode !== 'clamp' && spec.css) {
|
|
977
|
+
const handled = applyTileMode(spec.tileMode, spec.css);
|
|
978
|
+
if (handled) return Promise.resolve();
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
let gradient = null;
|
|
982
|
+
|
|
983
|
+
if (spec.animated) {
|
|
984
|
+
// T-vector animated gradient
|
|
985
|
+
gradient = buildAnimatedGradient(spec);
|
|
986
|
+
} else if (spec.css) {
|
|
987
|
+
gradient = buildGradientFromCSS(spec.css);
|
|
988
|
+
} else if (spec.colors) {
|
|
989
|
+
gradient = buildGradientFromColors(spec.colors);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
if (gradient) {
|
|
993
|
+
ctx.fillStyle = gradient;
|
|
994
|
+
ctx.fillRect(0, 0, W, H);
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
return Promise.resolve();
|
|
998
|
+
},
|
|
999
|
+
};
|
|
1000
|
+
|
|
1001
|
+
// Initialize
|
|
1002
|
+
init();
|
|
1003
|
+
</script>
|
|
1004
|
+
</body>
|
|
1005
|
+
</html>
|