-
-
Notifications
You must be signed in to change notification settings - Fork 89
/
util.js
788 lines (702 loc) · 25.7 KB
/
util.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
const perspective = require('gl-mat4/perspective');
const multiply = require('gl-mat4/multiply');
const lookAt = require('gl-mat4/lookAt');
const invert = require('gl-mat4/invert');
const rotate = require('gl-mat4/rotate');
const transform = require('gl-vec3/transformMat4');
const SVG_NS = 'http://www.w3.org/2000/svg';
// Taken from https://github.com/yuzhe-han/ParentNode-replaceChildren
// This is to support browsers that do not yet support `replaceChildren`
const replaceChildrenPonyfill = function (...addNodes) {
while (this.lastChild) {
this.removeChild(this.lastChild);
}
if (addNodes.length > 0) {
this.append(...addNodes);
}
};
module.exports = {
calculateSizingOptions,
createLogoViewer,
createModelRenderer,
loadModelFromJson,
positionsFromModel,
createPolygonsFromModelJson,
createStandardModelPolygon,
createMatrixComputer,
compareZ,
createFaceUpdater,
createNode,
setAttribute,
setGradientDefinitions,
setMaskDefinitions,
svgElementToSvgImageContent,
Polygon,
};
/**
* A distance measurement used for SVG attributes. A length is specified as a number followed by a
* unit identifier.
*
* See {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Content_type#length} for further
* information.
*
* @typedef {`${number}${'em' | 'ex' | 'px' | 'in' | 'cm' | 'mm' | 'pt' | 'pc' | '%'}`} SvgLength
*/
/**
* A definition for a `<stop>` SVG element, which defines a color and the position for that color
* on a gradient. This element is always a child of either a `<linearGradient>` or
* `<radialGradient>` element.
*
* See {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Element/stop} for more information
* about the `<stop>` element.
*
* @typedef {object} StopDefinition
* @property {number | `${number}%`} [offset] - The location of the gradient stop along the
* gradient vector.
* @property {string} [stop-color] - The color of the gradient stop. See {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Element/stop}.
* @property {number} [stop-opacity] - The opacity of the gradient stop. See {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/stop-opacity}.
*/
/**
* A definition for a `<linearGradient>` SVG element. This definition includes all supported
* `<linearGradient>` attributes, and it includes a `stops` property which is an array of
* definitions for each `<stop>` child node.
*
* See {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Element/linearGradient} for more
* information about the `<linearGradient>` element.
*
* @typedef {object} LinearGradientDefinition
* @property {string} [gradientTransform] - A transform from the gradient coordinate system to the
* target coordinate system. See {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/gradientTransform}.
* @property {'userSpaceOnUse' | 'objectBoundingBox'} [gradientUnits] - The coordinate system used.
* for the coordinate attributes. See {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/gradientUnits}.
* @property {'pad' | 'reflect' | 'repeat'} [spreadMethod] - The method used to fill a shape beyond
* the defined edges of a gradient. See {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/spreadMethod}.
* @property {StopDefinition[]} [stops] - The colors of the gradient, and the position of each
* color along the gradient vector.
* @property {'linear'} type - The type of the gradient.
* @property {SvgLength} [x1] - The x coordinate of the starting point of the vector gradient.
* @property {SvgLength} [x2] - The x coordinate of the ending point of the vector gradient.
* @property {SvgLength} [y1] - The y coordinate of the starting point of the vector gradient.
* @property {SvgLength} [y2] - The y coordinate of the ending point of the vector gradient.
*/
/**
* A definition for a `<radialGradient>` SVG element. This definition includes all supported
* `<radialGradient>` attributes, and it includes a `stops` property which is an array of
* definitions for each `<stop>` child node.
*
* See {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Element/radialGradient} for more
* information about the `<radialGradient>` element.
*
* @typedef {object} RadialGradientDefinition
* @property {SvgLength} [cx] - The x coordinate of the end circle of the radial gradiant.
* @property {SvgLength} [cy] - The y coordinate of the end circle of the radial gradient.
* @property {SvgLength} [fr] - The radius of the start circle of the radial gradient.
* @property {SvgLength} [fx] - The x coordinate of the start circle of the radial gradient.
* @property {SvgLength} [fy] - The y coordinate of the start circle of the radial gradient.
* @property {string} [gradientTransform] - A transform from the gradient coordinate system to the
* target coordinate system. See {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/gradientTransform}.
* @property {'userSpaceOnUse' | 'objectBoundingBox'} [gradientUnits] - The coordinate system used
* for the coordinate attributes. See {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/gradientUnits}.
* @property {SvgLength} [r] - The radius of the end circle of the radial gradient.
* @property {'pad' | 'reflect' | 'repeat'} [spreadMethod] - The method used to fill a shape beyond
* the defined edges of a gradient. See {@link https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/spreadMethod}.
* @property {StopDefinition[]} [stops] - The colors of the gradient, and the position of each
* color along the gradient vector.
* @property {'radial'} type - The type of the gradient.
*/
function createLogoViewer(
container,
renderScene,
{
followMouse = false,
followMotion = false,
slowDrift = false,
lazyRender = true,
} = {},
) {
let shouldRender = true;
const mouse = {
x: 0,
y: 0,
};
const lookCurrent = [0, 0];
const lookRate = 0.3;
// closes over scene state
const renderCurrentScene = () => {
updateLookCurrent();
renderScene(lookCurrent, slowDrift);
};
function setLookAtTarget(target) {
const bounds = container.getBoundingClientRect();
mouse.x = 1.0 - (2.0 * (target.x - bounds.left)) / bounds.width;
mouse.y = 1.0 - (2.0 * (target.y - bounds.top)) / bounds.height;
}
function stopAnimation() {
shouldRender = false;
}
function startAnimation() {
shouldRender = true;
}
function setFollowMouse(state) {
// eslint-disable-next-line no-param-reassign
followMouse = state;
}
function setFollowMotion(state) {
// eslint-disable-next-line no-param-reassign
followMotion = state;
}
window.addEventListener('mousemove', function (ev) {
if (!shouldRender) {
startAnimation();
}
if (followMouse) {
setLookAtTarget({
x: ev.clientX,
y: ev.clientY,
});
renderCurrentScene();
}
});
window.addEventListener('deviceorientation', function (event) {
if (!shouldRender) {
startAnimation();
}
if (followMotion) {
// gamma: left to right
const leftToRight = event.gamma;
// beta: front back motion
const frontToBack = event.beta;
// x offset: needed to correct the intial position
const xOffset = 200;
// y offset: needed to correct the intial position
const yOffset = -300;
// acceleration
const acceleration = 10;
setLookAtTarget({
x: xOffset + leftToRight * acceleration,
y: yOffset + frontToBack * acceleration,
});
renderCurrentScene();
}
});
function lookAtAndRender(target) {
// update look target
setLookAtTarget(target);
// this should prolly just call updateLookCurrent or set lookCurrent values to eaxactly lookTarget
// but im not really sure why its different, so im leaving it alone
lookCurrent[0] = mouse.x;
lookCurrent[1] = mouse.y + 0.085 / lookRate;
renderCurrentScene();
}
function renderLoop() {
if (!shouldRender) {
return;
}
window.requestAnimationFrame(renderLoop);
renderCurrentScene();
}
function updateLookCurrent() {
const li = 1.0 - lookRate;
lookCurrent[0] = li * lookCurrent[0] + lookRate * mouse.x;
lookCurrent[1] = li * lookCurrent[1] + lookRate * mouse.y + 0.085;
}
if (lazyRender) {
renderCurrentScene();
} else {
renderLoop();
}
return {
container,
lookAt: setLookAtTarget,
setFollowMouse,
setFollowMotion,
stopAnimation,
startAnimation,
lookAtAndRender,
renderCurrentScene,
};
}
function loadModelFromJson(
modelJson,
createSvgPolygon = createStandardModelPolygon,
) {
const vertCount = modelJson.positions.length;
const positions = new Float32Array(3 * vertCount);
const transformed = new Float32Array(3 * vertCount);
const { polygons, polygonsByChunk } = createPolygonsFromModelJson(
modelJson,
createSvgPolygon,
);
positionsFromModel(positions, modelJson);
const updatePositions = createPositionUpdater(
positions,
transformed,
vertCount,
);
const modelObj = {
updatePositions,
positions,
transformed,
polygons,
polygonsByChunk,
};
return modelObj;
}
function createModelRenderer(container, cameraDistance, modelObj) {
const { updatePositions, transformed, polygons } = modelObj;
for (const polygon of polygons) {
container.appendChild(polygon.svg);
}
const computeMatrix = createMatrixComputer(cameraDistance);
const updateFaces = createFaceUpdater(container, polygons, transformed);
return (rect, lookPos, slowDrift) => {
const matrix = computeMatrix(rect, lookPos, slowDrift);
updatePositions(matrix);
updateFaces(rect, container, polygons, transformed);
};
}
function positionsFromModel(positions, modelJson) {
const pp = modelJson.positions;
let ptr = 0;
for (let i = 0; i < pp.length; ++i) {
const p = pp[i];
for (let j = 0; j < 3; ++j) {
positions[ptr] = p[j];
ptr += 1;
}
}
}
function createPolygonsFromModelJson(modelJson, createSvgPolygon) {
const polygons = [];
const polygonsByChunk = modelJson.chunks.map((chunk, index) => {
const { faces } = chunk;
return faces.map((face) => {
const svgPolygon = createSvgPolygon(chunk, {
gradients: modelJson.gradients,
index,
masks: modelJson.masks,
});
const polygon = new Polygon(svgPolygon, face);
polygons.push(polygon);
return polygon;
});
});
return { polygons, polygonsByChunk };
}
/**
* Create an SVG `<polygon> element.
*
* This polygon is assigned the correct `fill` and `stroke` attributes, according to the chunk
* definition provided. But the `points` attribute is always set to a dummy value, as it gets reset
* later to the correct position during each render loop.
*
* @param {object} chunk - The definition for the chunk of the model this polygon is a part of.
* This includes the color or gradient to apply to the polygon.
* @param {object} options - Polygon options.
* @param {(LinearGradientDefinition | RadialGradientDefinition)[]} [options.gradients] - The set of
* all gradient definitions used in this model.
* @param options.index - The index for the chunk this polygon is found in.
* @returns {Element} The `<polygon>` SVG element.
*/
function createStandardModelPolygon(chunk, { gradients = {}, index, masks }) {
const svgPolygon = createNode('polygon');
if (chunk.gradient && chunk.color) {
throw new Error(
`Both gradient and color for chunk '${index}'. These options are mutually exclusive.`,
);
} else if (chunk.gradient) {
const gradientId = chunk.gradient;
if (!gradients[gradientId]) {
throw new Error(`Gradient ID not found: '${gradientId}'`);
}
setAttribute(svgPolygon, 'fill', `url('#${gradientId}')`);
setAttribute(svgPolygon, 'stroke', `url('#${gradientId}')`);
} else {
const fill =
typeof chunk.color === 'string' ? chunk.color : `rgb(${chunk.color})`;
setAttribute(svgPolygon, 'fill', fill);
setAttribute(svgPolygon, 'stroke', fill);
}
if (chunk.mask) {
if (!masks[chunk.mask]) {
throw new Error(`Mask ID not found: '${chunk.mask}'`);
}
setAttribute(svgPolygon, 'mask', `url('#${chunk.mask}')`);
}
setAttribute(svgPolygon, 'points', '0,0, 10,0, 0,10');
return svgPolygon;
}
function createMatrixComputer(distance) {
const objectCenter = new Float32Array(3);
const up = new Float32Array([0, 1, 0]);
const projection = new Float32Array(16);
const model = new Float32Array(16);
const view = lookAt(
new Float32Array(16),
new Float32Array([0, 0, distance]),
objectCenter,
up,
);
const invView = invert(new Float32Array(16), view);
const invProjection = new Float32Array(16);
const target = new Float32Array(3);
const transformedMatrix = new Float32Array(16);
const X = new Float32Array([1, 0, 0]);
const Y = new Float32Array([0, 1, 0]);
const Z = new Float32Array([0, 0, 1]);
return (rect, lookPos, slowDrift) => {
const viewportWidth = rect.width;
const viewportHeight = rect.height;
perspective(
projection,
Math.PI / 4.0,
viewportWidth / viewportHeight,
100.0,
1000.0,
);
invert(invProjection, projection);
target[0] = lookPos[0];
target[1] = lookPos[1];
target[2] = 1.2;
transform(target, target, invProjection);
transform(target, target, invView);
lookAt(model, objectCenter, target, up);
// this shouldnt operate directly on the matrix/model,
// it should likely operate on the lookPos
// if we do want to operate on the matrix/model, it shouldnt happen here
if (slowDrift) {
const time = Date.now() / 1000.0;
rotate(model, model, 0.1 + Math.sin(time / 3) * 0.2, X);
rotate(model, model, -0.1 + Math.sin(time / 2) * 0.03, Z);
rotate(model, model, 0.5 + Math.sin(time / 3) * 0.2, Y);
}
multiply(transformedMatrix, projection, view);
multiply(transformedMatrix, transformedMatrix, model);
return transformedMatrix;
};
}
function createPositionUpdater(positions, transformed, vertCount) {
return (M) => {
const m00 = M[0];
const m01 = M[1];
const m02 = M[2];
const m03 = M[3];
const m10 = M[4];
const m11 = M[5];
const m12 = M[6];
const m13 = M[7];
const m20 = M[8];
const m21 = M[9];
const m22 = M[10];
const m23 = M[11];
const m30 = M[12];
const m31 = M[13];
const m32 = M[14];
const m33 = M[15];
for (let i = 0; i < vertCount; ++i) {
const x = positions[3 * i];
const y = positions[3 * i + 1];
const z = positions[3 * i + 2];
const tw = x * m03 + y * m13 + z * m23 + m33;
transformed[3 * i] = (x * m00 + y * m10 + z * m20 + m30) / tw;
transformed[3 * i + 1] = (x * m01 + y * m11 + z * m21 + m31) / tw;
transformed[3 * i + 2] = (x * m02 + y * m12 + z * m22 + m32) / tw;
}
};
}
function compareZ(a, b) {
return b.zIndex - a.zIndex;
}
function createFaceUpdater(container, polygons, transformed) {
const toDraw = [];
return (rect) => {
let i;
const w = rect.width;
const h = rect.height;
toDraw.length = 0;
for (i = 0; i < polygons.length; ++i) {
const poly = polygons[i];
const { indices } = poly;
const i0 = indices[0];
const i1 = indices[1];
const i2 = indices[2];
const ax = transformed[3 * i0];
const ay = transformed[3 * i0 + 1];
const bx = transformed[3 * i1];
const by = transformed[3 * i1 + 1];
const cx = transformed[3 * i2];
const cy = transformed[3 * i2 + 1];
const det = (bx - ax) * (cy - ay) - (by - ay) * (cx - ax);
if (det < 0) {
continue;
}
const points = [];
let zmax = -Infinity;
let zmin = Infinity;
const element = poly.svg;
for (let j = 0; j < 3; ++j) {
const idx = indices[j];
points.push(
`${0.5 * w * (1.0 - transformed[3 * idx])},${
0.5 * h * (1.0 - transformed[3 * idx + 1])
}`,
);
const z = transformed[3 * idx + 2];
zmax = Math.max(zmax, z);
zmin = Math.min(zmin, z);
}
poly.zIndex = zmax + 0.25 * zmin;
const joinedPoints = points.join(' ');
if (joinedPoints.indexOf('NaN') === -1) {
setAttribute(element, 'points', joinedPoints);
}
toDraw.push(poly);
}
toDraw.sort(compareZ);
const newPolygons = toDraw.map((poly) => poly.svg);
const defs = container.getElementsByTagName('defs');
const maskChildren = container.getElementsByTagName('mask');
if (container.replaceChildren) {
container.replaceChildren(...defs, ...maskChildren, ...newPolygons);
} else {
replaceChildrenPonyfill.bind(container)(
...defs,
...maskChildren,
...newPolygons,
);
}
};
}
function calculateSizingOptions(options = {}) {
let width = options.width || 400;
let height = options.height || 400;
if (!options.pxNotRatio) {
width = Math.floor(window.innerWidth * (options.width || 0.25));
height = Math.floor(window.innerHeight * options.height || width);
if ('minWidth' in options && width < options.minWidth) {
width = options.minWidth;
height = Math.floor((options.minWidth * options.height) / options.width);
}
}
return { width, height };
}
function createNode(type) {
return document.createElementNS(SVG_NS, type);
}
function setAttribute(node, attribute, value) {
node.setAttributeNS(null, attribute, value);
}
function svgElementToSvgImageContent(svgElement) {
const inner = svgElement.innerHTML;
const head =
`<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> ` +
`<svg width="521px" height="521px" version="1.1" baseProfile="full" xmlns="${SVG_NS}" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:ev="http://www.w3.org/2001/xml-events">`;
const foot = '</svg>';
const content = head + inner + foot;
return content;
}
function Polygon(svg, indices) {
this.svg = svg;
this.indices = indices;
this.zIndex = 0;
}
/**
* Parse gradient definitions and construct them in the DOM.
*
* Both `<linearGradient>` and `<radialGradient>` are supported. All gradients get added to a
* `<defs>` element that is added as a direct child of the container element.
*
* @param {Element} container - The `<svg>` HTML element that the definitions should be added to.
* @param {(LinearGradientDefinition | RadialGradientDefinition)[]} [gradients] - The gradient definitions.
*/
function setGradientDefinitions(container, gradients) {
if (!gradients || Object.keys(gradients).length === 0) {
return;
}
const defsContainer = createNode('defs');
const linearCoordinateAttributes = ['x1', 'x2', 'y1', 'y2'];
const radialCoordinateAttributes = ['cx', 'cy', 'fr', 'fx', 'fy', 'r'];
const commonAttributes = [
'gradientTransform',
'gradientUnits',
'spreadMethod',
'stops',
'type',
];
const allLinearAttributes = [
...linearCoordinateAttributes,
...commonAttributes,
];
const allRadialAttributes = [
...radialCoordinateAttributes,
...commonAttributes,
];
for (const [gradientId, gradientDefinition] of Object.entries(gradients)) {
let gradient;
if (gradientDefinition.type === 'linear') {
gradient = createNode('linearGradient');
const unsupportedLinearAttribute = Object.keys(gradientDefinition).find(
(attribute) => !allLinearAttributes.includes(attribute),
);
if (unsupportedLinearAttribute) {
throw new Error(
`Unsupported linear gradient attribute: '${unsupportedLinearAttribute}'`,
);
} else if (
linearCoordinateAttributes.some(
(attributeName) => gradientDefinition[attributeName] !== undefined,
)
) {
const missingAttributes = linearCoordinateAttributes.filter(
(attributeName) => gradientDefinition[attributeName] === undefined,
);
if (missingAttributes.length > 0) {
throw new Error(
`Missing coordinate attributes: '${missingAttributes.join(', ')}'`,
);
}
for (const attribute of linearCoordinateAttributes) {
if (typeof gradientDefinition[attribute] !== 'string') {
throw new Error(
`Type of '${attribute}' option expected to be 'string'. Instead received type '${typeof gradientDefinition[
attribute
]}'`,
);
}
setAttribute(gradient, attribute, gradientDefinition[attribute]);
}
}
} else if (gradientDefinition.type === 'radial') {
gradient = createNode('radialGradient');
const presentCoordinateAttributes = radialCoordinateAttributes.filter(
(attributeName) => gradientDefinition[attributeName] !== undefined,
);
const unsupportedRadialAttribute = Object.keys(gradientDefinition).find(
(attribute) => !allRadialAttributes.includes(attribute),
);
if (unsupportedRadialAttribute) {
throw new Error(
`Unsupported radial gradient attribute: '${unsupportedRadialAttribute}'`,
);
} else if (presentCoordinateAttributes.length > 0) {
for (const attribute of presentCoordinateAttributes) {
if (typeof gradientDefinition[attribute] !== 'string') {
throw new Error(
`Type of '${attribute}' option expected to be 'string'. Instead received type '${typeof gradientDefinition[
attribute
]}'`,
);
}
setAttribute(gradient, attribute, gradientDefinition[attribute]);
}
}
} else {
throw new Error(
`Unsupported gradient type: '${gradientDefinition.type}'`,
);
}
// Set common attributes
setAttribute(gradient, 'id', gradientId);
if (gradientDefinition.gradientUnits !== undefined) {
if (
!['userSpaceOnUse', 'objectBoundingBox'].includes(
gradientDefinition.gradientUnits,
)
) {
throw new Error(
`Unrecognized value for 'gradientUnits' attribute: '${gradientDefinition.gradientUnits}'`,
);
}
setAttribute(gradient, 'gradientUnits', gradientDefinition.gradientUnits);
}
if (gradientDefinition.gradientTransform !== undefined) {
if (typeof gradientDefinition.gradientTransform !== 'string') {
throw new Error(
`Type of 'gradientTransform' option expected to be 'string'. Instead received type '${typeof gradientDefinition.gradientTransform}'`,
);
}
setAttribute(
gradient,
'gradientTransform',
gradientDefinition.gradientTransform,
);
}
if (gradientDefinition.spreadMethod !== undefined) {
if (
!['pad', 'reflect', 'repeat'].includes(gradientDefinition.spreadMethod)
) {
throw new Error(
`Unrecognized value for 'spreadMethod' attribute: '${gradientDefinition.spreadMethod}'`,
);
}
setAttribute(gradient, 'spreadMethod', gradientDefinition.spreadMethod);
}
if (gradientDefinition.stops !== undefined) {
if (!Array.isArray(gradientDefinition.stops)) {
throw new Error(`The 'stop' attribute must be an array`);
}
for (const stopDefinition of gradientDefinition.stops) {
if (typeof stopDefinition !== 'object') {
throw new Error(
`Each entry in the 'stop' attribute must be an object. Instead received type '${typeof stopDefinition}'`,
);
}
const stop = createNode('stop');
if (stopDefinition.offset !== undefined) {
setAttribute(stop, 'offset', stopDefinition.offset);
}
if (stopDefinition['stop-color'] !== undefined) {
setAttribute(stop, 'stop-color', stopDefinition['stop-color']);
}
if (stopDefinition['stop-opacity'] !== undefined) {
setAttribute(stop, 'stop-opacity', stopDefinition['stop-opacity']);
}
gradient.appendChild(stop);
}
}
defsContainer.appendChild(gradient);
}
container.appendChild(defsContainer);
}
/**
* The properties of a single SVG mask.
*
* @typedef MaskDefinition
* @property {string} color - The color or gradient to apply to the mask.
*/
/**
* Parse mask definitions and construct them in the DOM.
*
* The `<mask>` element contains a single rectangle that should cover the full extent of the SVG
* model. The color of this rectangle can be set to single color or a gradient. Anything the mask
* is applied to will be invisible if under a black pixel, visible if under a white pixel, and
* partially translucent if under a pixel that is between white and black.
*
* Later this could be extended to include custom paths and other shapes, rather than just a single
* rectangle.
*
* @param options - The mask options.
* @param {Element} options.container - The `<svg>` HTML element that the mask should be added to.
* @param {Record<string, MaskDefinition>} [options.masks] - The gradient definitions.
* @param {number} options.height - The height of the SVG container.
* @param {number} options.width - The width of the SVG container.
*/
function setMaskDefinitions({ container, masks, height, width }) {
if (!masks || Object.keys(masks).length === 0) {
return;
}
for (const [maskId, maskDefinition] of Object.entries(masks)) {
const mask = createNode('mask');
setAttribute(mask, 'id', maskId);
const maskedRect = createNode('rect');
// Extend mask beyond container to ensure it completely covers the model.
// The model can extend beyond the container as well.
setAttribute(maskedRect, 'width', width * 1.5);
setAttribute(maskedRect, 'height', height * 1.5);
setAttribute(maskedRect, 'x', `-${Math.floor(width / 4)}`);
setAttribute(maskedRect, 'y', `-${Math.floor(height / 4)}`);
setAttribute(maskedRect, 'fill', maskDefinition.color);
mask.appendChild(maskedRect);
container.appendChild(mask);
}
}