capture-flow 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +238 -2
- package/bin/capture-flow.mjs +12 -0
- package/package.json +55 -4
- package/src/build.ts +186 -0
- package/src/capture.ts +77 -0
- package/src/cli.ts +139 -0
- package/src/index.ts +11 -0
- package/src/manifest.ts +98 -0
- package/src/render.ts +75 -0
- package/src/verify.ts +90 -0
- package/vendor/wajha/LICENSE +21 -0
- package/vendor/wajha/NOTICE +21 -0
- package/vendor/wajha/engine/background.ts +55 -0
- package/vendor/wajha/engine/bidi.ts +589 -0
- package/vendor/wajha/engine/cache.ts +96 -0
- package/vendor/wajha/engine/color.ts +125 -0
- package/vendor/wajha/engine/devices/android.ts +53 -0
- package/vendor/wajha/engine/devices/browser.ts +170 -0
- package/vendor/wajha/engine/devices/finish.ts +154 -0
- package/vendor/wajha/engine/devices/geometry.ts +326 -0
- package/vendor/wajha/engine/devices/index.ts +143 -0
- package/vendor/wajha/engine/devices/ipad.ts +21 -0
- package/vendor/wajha/engine/devices/iphone.ts +55 -0
- package/vendor/wajha/engine/devices/macbook.ts +187 -0
- package/vendor/wajha/engine/devices/mock/apps/calendar.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/chat.ts +213 -0
- package/vendor/wajha/engine/devices/mock/apps/finance.ts +164 -0
- package/vendor/wajha/engine/devices/mock/apps/fitness.ts +145 -0
- package/vendor/wajha/engine/devices/mock/apps/index.ts +14 -0
- package/vendor/wajha/engine/devices/mock/apps/meditation.ts +167 -0
- package/vendor/wajha/engine/devices/mock/apps/music.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/shop.ts +116 -0
- package/vendor/wajha/engine/devices/mock/apps/social.ts +152 -0
- package/vendor/wajha/engine/devices/mock/apps/tasks.ts +131 -0
- package/vendor/wajha/engine/devices/mock/apps/travel.ts +121 -0
- package/vendor/wajha/engine/devices/mock/art.ts +557 -0
- package/vendor/wajha/engine/devices/mock/chrome.ts +337 -0
- package/vendor/wajha/engine/devices/mock/icons.ts +614 -0
- package/vendor/wajha/engine/devices/mock/kit.ts +213 -0
- package/vendor/wajha/engine/devices/mock/rng.ts +40 -0
- package/vendor/wajha/engine/devices/mock/theme.ts +133 -0
- package/vendor/wajha/engine/devices/mock/widgets.ts +231 -0
- package/vendor/wajha/engine/devices/paths.ts +113 -0
- package/vendor/wajha/engine/devices/screen.ts +75 -0
- package/vendor/wajha/engine/devices/shadow.ts +124 -0
- package/vendor/wajha/engine/devices/slab.ts +200 -0
- package/vendor/wajha/engine/devices/vision.ts +43 -0
- package/vendor/wajha/engine/devices/watch.ts +14 -0
- package/vendor/wajha/engine/geometry.ts +152 -0
- package/vendor/wajha/engine/images.ts +96 -0
- package/vendor/wajha/engine/index.ts +20 -0
- package/vendor/wajha/engine/linebreak.ts +226 -0
- package/vendor/wajha/engine/markup.ts +84 -0
- package/vendor/wajha/engine/mock.ts +158 -0
- package/vendor/wajha/engine/paint.ts +474 -0
- package/vendor/wajha/engine/patterns.ts +263 -0
- package/vendor/wajha/engine/render.ts +364 -0
- package/vendor/wajha/engine/shapes.ts +260 -0
- package/vendor/wajha/engine/testing/canvasNode.ts +121 -0
- package/vendor/wajha/engine/testing/fakeDevices.ts +85 -0
- package/vendor/wajha/engine/text.ts +694 -0
- package/vendor/wajha/engine/types.ts +190 -0
- package/vendor/wajha/export/paths.ts +197 -0
- package/vendor/wajha/export/png.ts +223 -0
- package/vendor/wajha/export/types.ts +60 -0
- package/vendor/wajha/model/adapt.ts +889 -0
- package/vendor/wajha/model/catalog.ts +369 -0
- package/vendor/wajha/model/clone.ts +58 -0
- package/vendor/wajha/model/colorMath.ts +128 -0
- package/vendor/wajha/model/defaults.ts +386 -0
- package/vendor/wajha/model/geometry.ts +95 -0
- package/vendor/wajha/model/ids.ts +13 -0
- package/vendor/wajha/model/instantiate.ts +280 -0
- package/vendor/wajha/model/mirror.ts +43 -0
- package/vendor/wajha/model/resolve.ts +87 -0
- package/vendor/wajha/model/rules.ts +249 -0
- package/vendor/wajha/model/stub.ts +4 -0
- package/vendor/wajha/model/types.ts +456 -0
- package/vendor/wajha/model/typography.ts +28 -0
- package/vendor/wajha/model/validate.ts +917 -0
- package/vendor/wajha/templates/builtin/aurora.ts +163 -0
- package/vendor/wajha/templates/builtin/bubblegum.ts +132 -0
- package/vendor/wajha/templates/builtin/citrusPop.ts +124 -0
- package/vendor/wajha/templates/builtin/clayGarden.ts +114 -0
- package/vendor/wajha/templates/builtin/desertDune.ts +97 -0
- package/vendor/wajha/templates/builtin/glassPrism.ts +139 -0
- package/vendor/wajha/templates/builtin/launchpad.ts +140 -0
- package/vendor/wajha/templates/builtin/loudAndClear.ts +109 -0
- package/vendor/wajha/templates/builtin/midnightNeon.ts +108 -0
- package/vendor/wajha/templates/builtin/monoSwiss.ts +149 -0
- package/vendor/wajha/templates/builtin/noirGold.ts +149 -0
- package/vendor/wajha/templates/builtin/noor.ts +107 -0
- package/vendor/wajha/templates/builtin/obsidian.ts +137 -0
- package/vendor/wajha/templates/builtin/panoramaPeaks.ts +145 -0
- package/vendor/wajha/templates/builtin/panoramaRibbon.ts +138 -0
- package/vendor/wajha/templates/builtin/panoramaWaves.ts +138 -0
- package/vendor/wajha/templates/builtin/paperAndInk.ts +98 -0
- package/vendor/wajha/templates/builtin/sage.ts +94 -0
- package/vendor/wajha/templates/builtin/splitTone.ts +129 -0
- package/vendor/wajha/templates/builtin/sunsetBoulevard.ts +127 -0
- package/vendor/wajha/templates/index.ts +55 -0
- package/vendor/wajha/templates/kit.ts +761 -0
- package/vendor/wajha/ui/sortShots.ts +239 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Procedural "photography" for mock screens: landscapes, abstract auras,
|
|
3
|
+
* album covers, product shots, avatars and a stylised map. Each piece is
|
|
4
|
+
* seeded by its own key, so it does not depend on drawing order.
|
|
5
|
+
*/
|
|
6
|
+
import type { Ctx2D } from '../../types';
|
|
7
|
+
import { darken, lighten, mix, rgba, rotateHue } from '../../color';
|
|
8
|
+
import { roundRectPath, type Radii } from '../paths';
|
|
9
|
+
import type { Mk } from './kit';
|
|
10
|
+
import { circle, linear, radial, smoothPath } from './kit';
|
|
11
|
+
import { icon, type IconName } from './icons';
|
|
12
|
+
import { createRng, type Rng } from './rng';
|
|
13
|
+
|
|
14
|
+
/** Index into a list of `n` items for any key, including negatives, fractions and NaN. */
|
|
15
|
+
function slot(k: number, n: number): number {
|
|
16
|
+
const i = Number.isFinite(k) ? Math.floor(k) : 0;
|
|
17
|
+
return ((i % n) + n) % n;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface Scene {
|
|
21
|
+
sky: [string, string];
|
|
22
|
+
sun: string;
|
|
23
|
+
ridges: [string, string, string];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SCENES: readonly Scene[] = [
|
|
27
|
+
{ sky: ['#ff9f7a', '#ffe0c2'], sun: '#fff6e8', ridges: ['#e89c8f', '#b96a78', '#5e3a5c'] },
|
|
28
|
+
{ sky: ['#6aa8ff', '#d9eaff'], sun: '#ffffff', ridges: ['#a3bfe0', '#5f86b8', '#2b4b78'] },
|
|
29
|
+
{ sky: ['#2a1c52', '#d07a8c'], sun: '#ffd7a8', ridges: ['#8a5583', '#523065', '#21153a'] },
|
|
30
|
+
{ sky: ['#8fd9cb', '#f2f8e6'], sun: '#ffffff', ridges: ['#94caa9', '#4f9b7b', '#1f5c4b'] },
|
|
31
|
+
{ sky: ['#ffc65c', '#fff0c4'], sun: '#fffaf0', ridges: ['#eaa65c', '#c5733a', '#7a3f20'] },
|
|
32
|
+
{ sky: ['#0e2346', '#4a78b0'], sun: '#eef4ff', ridges: ['#36608f', '#1f3f68', '#0b1b33'] },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/** Blends a scene towards the brand colour so photos feel part of the same app. */
|
|
36
|
+
function sceneFor(m: Mk, k: number): Scene {
|
|
37
|
+
const base = SCENES[slot(k, SCENES.length)];
|
|
38
|
+
const a = m.t.accent;
|
|
39
|
+
const tintC = (c: string) => mix(c, a, 0.12);
|
|
40
|
+
return {
|
|
41
|
+
sky: [tintC(base.sky[0]), tintC(base.sky[1])],
|
|
42
|
+
sun: base.sun,
|
|
43
|
+
ridges: [tintC(base.ridges[0]), tintC(base.ridges[1]), tintC(base.ridges[2])],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function clipRound(m: Mk, x: number, y: number, w: number, h: number, r: number | Radii): void {
|
|
48
|
+
m.ctx.beginPath();
|
|
49
|
+
roundRectPath(m.ctx, x, y, w, h, r);
|
|
50
|
+
m.ctx.clip();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function ridge(ctx: Ctx2D, rng: Rng, x: number, y: number, w: number, h: number, base: number, amp: number, n: number): void {
|
|
54
|
+
const pts: [number, number][] = [];
|
|
55
|
+
for (let i = 0; i <= n; i++) {
|
|
56
|
+
const px = x + (w * i) / n;
|
|
57
|
+
const peak = i % 2 === 0 ? rng.range(0.35, 1) : rng.range(0, 0.3);
|
|
58
|
+
pts.push([px, y + h * base - amp * h * peak]);
|
|
59
|
+
}
|
|
60
|
+
ctx.beginPath();
|
|
61
|
+
ctx.moveTo(x, y + h);
|
|
62
|
+
smoothPath(ctx, pts, false, 0.75);
|
|
63
|
+
ctx.lineTo(x + w, y + h);
|
|
64
|
+
ctx.closePath();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A landscape or abstract photo filling the rounded rect. */
|
|
68
|
+
export function photo(m: Mk, x: number, y: number, w: number, h: number, r: number | Radii, k: number): void {
|
|
69
|
+
if (!(w > 0) || !(h > 0)) return;
|
|
70
|
+
const ctx = m.ctx;
|
|
71
|
+
const rng = createRng(k * 7919 + 17);
|
|
72
|
+
const kind = slot(k, 4);
|
|
73
|
+
const sc = sceneFor(m, Math.floor(k / 4) + k);
|
|
74
|
+
ctx.save();
|
|
75
|
+
clipRound(m, x, y, w, h, r);
|
|
76
|
+
if (kind === 3) {
|
|
77
|
+
aura(m, x, y, w, h, rng);
|
|
78
|
+
ctx.restore();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
ctx.fillStyle = linear(m, 0, y, 0, y + h, [
|
|
82
|
+
[0, sc.sky[0]],
|
|
83
|
+
[0.7, sc.sky[1]],
|
|
84
|
+
]);
|
|
85
|
+
ctx.fillRect(x, y, w, h);
|
|
86
|
+
const sx = x + w * rng.range(0.25, 0.75);
|
|
87
|
+
const sy = y + h * (kind === 1 ? 0.56 : rng.range(0.28, 0.42));
|
|
88
|
+
const sr = Math.min(w, h) * rng.range(0.08, 0.13);
|
|
89
|
+
ctx.fillStyle = radial(m, sx, sy, sr * 4.5, [
|
|
90
|
+
[0, rgba(sc.sun, 0.75)],
|
|
91
|
+
[0.25, rgba(sc.sun, 0.25)],
|
|
92
|
+
[1, rgba(sc.sun, 0)],
|
|
93
|
+
]);
|
|
94
|
+
ctx.fillRect(x, y, w, h);
|
|
95
|
+
circle(m, sx, sy, sr, sc.sun);
|
|
96
|
+
|
|
97
|
+
if (kind === 1) {
|
|
98
|
+
// Sea: horizon, water gradient and a broken sun reflection.
|
|
99
|
+
const hy = y + h * 0.6;
|
|
100
|
+
ctx.fillStyle = linear(m, 0, hy, 0, y + h, [
|
|
101
|
+
[0, mix(sc.ridges[1], sc.sky[1], 0.35)],
|
|
102
|
+
[1, sc.ridges[2]],
|
|
103
|
+
]);
|
|
104
|
+
ctx.fillRect(x, hy, w, y + h - hy);
|
|
105
|
+
for (let i = 0; i < 7; i++) {
|
|
106
|
+
const ry = hy + (y + h - hy) * (0.08 + i * 0.13);
|
|
107
|
+
const rw = sr * (2.2 - i * 0.2) * rng.range(0.7, 1.2);
|
|
108
|
+
ctx.fillStyle = rgba(sc.sun, 0.5 - i * 0.05);
|
|
109
|
+
ctx.beginPath();
|
|
110
|
+
roundRectPath(ctx, sx - rw / 2, ry, rw, Math.max(1, h * 0.012), h * 0.006);
|
|
111
|
+
ctx.fill();
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
// Mountains (kind 0) or dunes (kind 2) with atmospheric perspective.
|
|
115
|
+
const n = kind === 0 ? 6 : 3;
|
|
116
|
+
const amp = kind === 0 ? 0.3 : 0.12;
|
|
117
|
+
[0.66, 0.78, 0.9].forEach((base, i) => {
|
|
118
|
+
ridge(ctx, rng, x - w * 0.05, y, w * 1.1, h, base, amp * (1 - i * 0.18), n + i);
|
|
119
|
+
ctx.fillStyle = linear(m, 0, y + h * (base - amp), 0, y + h, [
|
|
120
|
+
[0, sc.ridges[i]],
|
|
121
|
+
[1, darken(sc.ridges[i], 0.18)],
|
|
122
|
+
]);
|
|
123
|
+
ctx.fill();
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
ctx.restore();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Soft colour blobs, the iOS-wallpaper look. */
|
|
130
|
+
export function aura(m: Mk, x: number, y: number, w: number, h: number, rng: Rng): void {
|
|
131
|
+
const ctx = m.ctx;
|
|
132
|
+
const t = m.t;
|
|
133
|
+
// Analogous hues only: overlapping blobs of distant hues blend into mud.
|
|
134
|
+
const spin = rng.range(-30, 30);
|
|
135
|
+
const base = rotateHue(t.accent, spin);
|
|
136
|
+
ctx.fillStyle = linear(m, x, y, x + w, y + h, [
|
|
137
|
+
[0, lighten(base, 0.38)],
|
|
138
|
+
[1, darken(rotateHue(t.companion, spin), 0.12)],
|
|
139
|
+
]);
|
|
140
|
+
ctx.fillRect(x, y, w, h);
|
|
141
|
+
const blobs = [rotateHue(base, 42), rotateHue(base, -38), lighten(base, 0.62), rotateHue(t.companion, spin)];
|
|
142
|
+
blobs.forEach((c, i) => {
|
|
143
|
+
const cx = x + w * rng.range(0.1, 0.9);
|
|
144
|
+
const cy = y + h * rng.range(0.1, 0.9);
|
|
145
|
+
const r = Math.max(w, h) * rng.range(0.35, 0.6);
|
|
146
|
+
ctx.fillStyle = radial(m, cx, cy, r, [
|
|
147
|
+
[0, rgba(c, i === 2 ? 0.7 : 0.85)],
|
|
148
|
+
[1, rgba(c, 0)],
|
|
149
|
+
]);
|
|
150
|
+
ctx.fillRect(x, y, w, h);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Square album cover in one of four graphic styles. */
|
|
155
|
+
export function albumArt(m: Mk, x: number, y: number, s: number, r: number, k: number): void {
|
|
156
|
+
if (!(s > 0)) return;
|
|
157
|
+
const ctx = m.ctx;
|
|
158
|
+
const rng = createRng(k * 104729 + 3);
|
|
159
|
+
const hue = rng.range(-60, 60);
|
|
160
|
+
const c0 = rotateHue(m.t.accent, hue);
|
|
161
|
+
const c1 = rotateHue(c0, rng.chance(0.5) ? 38 : -42);
|
|
162
|
+
ctx.save();
|
|
163
|
+
clipRound(m, x, y, s, s, r);
|
|
164
|
+
const style = slot(k, 4);
|
|
165
|
+
if (style === 0) {
|
|
166
|
+
aura(m, x, y, s, s, rng);
|
|
167
|
+
} else if (style === 1) {
|
|
168
|
+
// Sunset stripes behind a sun disc.
|
|
169
|
+
ctx.fillStyle = linear(m, 0, y, 0, y + s, [
|
|
170
|
+
[0, darken(c0, 0.45)],
|
|
171
|
+
[1, c1],
|
|
172
|
+
]);
|
|
173
|
+
ctx.fillRect(x, y, s, s);
|
|
174
|
+
circle(m, x + s / 2, y + s * 0.52, s * 0.28, linear(m, 0, y + s * 0.24, 0, y + s * 0.8, [
|
|
175
|
+
[0, lighten(c1, 0.6)],
|
|
176
|
+
[1, c0],
|
|
177
|
+
]));
|
|
178
|
+
for (let i = 0; i < 5; i++) {
|
|
179
|
+
ctx.fillStyle = darken(c0, 0.45);
|
|
180
|
+
ctx.fillRect(x, y + s * (0.58 + i * 0.055), s, s * (0.012 + i * 0.006));
|
|
181
|
+
}
|
|
182
|
+
} else if (style === 2) {
|
|
183
|
+
// Bauhaus shapes.
|
|
184
|
+
ctx.fillStyle = lighten(c1, 0.75);
|
|
185
|
+
ctx.fillRect(x, y, s, s);
|
|
186
|
+
circle(m, x + s * 0.36, y + s * 0.4, s * 0.26, c0);
|
|
187
|
+
ctx.fillStyle = darken(c1, 0.25);
|
|
188
|
+
ctx.beginPath();
|
|
189
|
+
ctx.moveTo(x + s * 0.5, y + s * 0.95);
|
|
190
|
+
ctx.lineTo(x + s * 0.95, y + s * 0.95);
|
|
191
|
+
ctx.lineTo(x + s * 0.95, y + s * 0.5);
|
|
192
|
+
ctx.closePath();
|
|
193
|
+
ctx.fill();
|
|
194
|
+
circle(m, x + s * 0.74, y + s * 0.26, s * 0.08, darken(c0, 0.35));
|
|
195
|
+
} else {
|
|
196
|
+
// Concentric waves.
|
|
197
|
+
ctx.fillStyle = darken(c0, 0.55);
|
|
198
|
+
ctx.fillRect(x, y, s, s);
|
|
199
|
+
for (let i = 9; i >= 1; i--) {
|
|
200
|
+
circle(m, x + s * 0.5, y + s * 1.05, s * 0.12 * i, mix(c0, c1, i / 9));
|
|
201
|
+
circle(m, x + s * 0.5, y + s * 1.05, s * 0.12 * i - s * 0.03, darken(mix(c0, c1, i / 9), 0.25));
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Gentle vignette, like printed cover art.
|
|
205
|
+
ctx.fillStyle = radial(m, x + s / 2, y + s / 2, s * 0.75, [
|
|
206
|
+
[0.55, 'rgba(0, 0, 0, 0)'],
|
|
207
|
+
[1, 'rgba(0, 0, 0, 0.18)'],
|
|
208
|
+
]);
|
|
209
|
+
ctx.fillRect(x, y, s, s);
|
|
210
|
+
ctx.restore();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const SKIN = ['#f2c9a9', '#e0aa84', '#c68863', '#9a613f', '#6e432b', '#f6d8c0'];
|
|
214
|
+
const HAIR = ['#2b1d16', '#4a2f20', '#7a4b2a', '#1b1b1f', '#b98554', '#3a2a24'];
|
|
215
|
+
|
|
216
|
+
/** Person avatar: pastel backdrop, shoulders, head and hair. */
|
|
217
|
+
export function avatar(m: Mk, cx: number, cy: number, d: number, k: number): void {
|
|
218
|
+
if (!(d > 0)) return;
|
|
219
|
+
const ctx = m.ctx;
|
|
220
|
+
const rng = createRng(k * 31337 + 11);
|
|
221
|
+
const r = d / 2;
|
|
222
|
+
const hue = rng.range(0, 360);
|
|
223
|
+
const bgA = rotateHue('#9db7ff', hue);
|
|
224
|
+
const bgB = rotateHue('#ffd0e0', hue);
|
|
225
|
+
ctx.save();
|
|
226
|
+
ctx.beginPath();
|
|
227
|
+
ctx.arc(cx, cy, r, 0, Math.PI * 2);
|
|
228
|
+
ctx.clip();
|
|
229
|
+
ctx.fillStyle = linear(m, cx - r, cy - r, cx + r, cy + r, [
|
|
230
|
+
[0, lighten(bgA, 0.25)],
|
|
231
|
+
[1, lighten(bgB, 0.1)],
|
|
232
|
+
]);
|
|
233
|
+
ctx.fillRect(cx - r, cy - r, d, d);
|
|
234
|
+
if (d < 14) {
|
|
235
|
+
ctx.restore();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const skin = rng.pick(SKIN);
|
|
239
|
+
const hair = rng.pick(HAIR);
|
|
240
|
+
const shirt = rotateHue(m.t.accents[rng.int(0, 3)], rng.range(-20, 20));
|
|
241
|
+
const longHair = rng.chance(0.45);
|
|
242
|
+
if (longHair) {
|
|
243
|
+
ctx.fillStyle = hair;
|
|
244
|
+
ctx.beginPath();
|
|
245
|
+
ctx.ellipse(cx, cy + r * 0.05, r * 0.42, r * 0.52, 0, 0, Math.PI * 2);
|
|
246
|
+
ctx.fill();
|
|
247
|
+
}
|
|
248
|
+
ctx.fillStyle = shirt;
|
|
249
|
+
ctx.beginPath();
|
|
250
|
+
ctx.ellipse(cx, cy + r * 1.08, r * 0.78, r * 0.62, 0, 0, Math.PI * 2);
|
|
251
|
+
ctx.fill();
|
|
252
|
+
ctx.fillStyle = darken(skin, 0.12);
|
|
253
|
+
ctx.fillRect(cx - r * 0.13, cy + r * 0.2, r * 0.26, r * 0.32);
|
|
254
|
+
ctx.fillStyle = skin;
|
|
255
|
+
ctx.beginPath();
|
|
256
|
+
ctx.ellipse(cx, cy - r * 0.06, r * 0.32, r * 0.39, 0, 0, Math.PI * 2);
|
|
257
|
+
ctx.fill();
|
|
258
|
+
ctx.fillStyle = hair;
|
|
259
|
+
ctx.beginPath();
|
|
260
|
+
ctx.ellipse(cx, cy - r * 0.26, r * 0.35, r * 0.24, 0, Math.PI, Math.PI * 2);
|
|
261
|
+
ctx.ellipse(cx + r * 0.05, cy - r * 0.27, r * 0.3, r * 0.12, -0.2, 0, Math.PI);
|
|
262
|
+
ctx.fill();
|
|
263
|
+
ctx.restore();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** A row of overlapping avatars with a ring in the surface colour. */
|
|
267
|
+
export function avatarStack(m: Mk, x: number, cy: number, d: number, count: number, k: number, ring: string): void {
|
|
268
|
+
for (let i = count - 1; i >= 0; i--) {
|
|
269
|
+
const cx = x + d / 2 + i * d * 0.66;
|
|
270
|
+
circle(m, cx, cy, d / 2 + Math.max(1, d * 0.07), ring);
|
|
271
|
+
avatar(m, cx, cy, d, k + i);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** Product photo: soft studio backdrop (unless `studio` is false), floor shadow and one of four objects. */
|
|
276
|
+
export function product(m: Mk, x: number, y: number, w: number, h: number, r: number | Radii, k: number, studio = true): void {
|
|
277
|
+
if (!(w > 0) || !(h > 0)) return;
|
|
278
|
+
const ctx = m.ctx;
|
|
279
|
+
const rng = createRng(k * 6007 + 5);
|
|
280
|
+
const hue = rng.range(-40, 40);
|
|
281
|
+
const col = rotateHue(m.t.accents[slot(k, 4)], hue);
|
|
282
|
+
ctx.save();
|
|
283
|
+
clipRound(m, x, y, w, h, r);
|
|
284
|
+
if (studio) {
|
|
285
|
+
const backdrop = m.t.dark ? mix(col, '#15161b', 0.82) : mix(col, '#ffffff', 0.84);
|
|
286
|
+
ctx.fillStyle = linear(m, 0, y, 0, y + h, [
|
|
287
|
+
[0, lighten(backdrop, 0.25)],
|
|
288
|
+
[1, darken(backdrop, 0.05)],
|
|
289
|
+
]);
|
|
290
|
+
ctx.fillRect(x, y, w, h);
|
|
291
|
+
}
|
|
292
|
+
const s = Math.min(w, h);
|
|
293
|
+
const cx = x + w / 2;
|
|
294
|
+
const floor = y + h * 0.82;
|
|
295
|
+
ctx.fillStyle = radial(m, cx, floor, s * 0.36, [
|
|
296
|
+
[0, 'rgba(0, 0, 0, 0.22)'],
|
|
297
|
+
[1, 'rgba(0, 0, 0, 0)'],
|
|
298
|
+
]);
|
|
299
|
+
ctx.save();
|
|
300
|
+
ctx.translate(cx, floor);
|
|
301
|
+
ctx.scale(1, 0.18);
|
|
302
|
+
ctx.translate(-cx, -floor);
|
|
303
|
+
ctx.fillRect(cx - s * 0.4, floor - s * 0.4, s * 0.8, s * 0.8);
|
|
304
|
+
ctx.restore();
|
|
305
|
+
|
|
306
|
+
const shade = (x0: number, x1: number, c: string) =>
|
|
307
|
+
linear(m, x0, 0, x1, 0, [
|
|
308
|
+
[0, darken(c, 0.28)],
|
|
309
|
+
[0.35, lighten(c, 0.22)],
|
|
310
|
+
[0.6, c],
|
|
311
|
+
[1, darken(c, 0.35)],
|
|
312
|
+
]);
|
|
313
|
+
// Cycle objects and colours at different rates so neighbours rarely repeat.
|
|
314
|
+
const kind = slot(slot(k, 4) + Math.floor(k / 4), 4);
|
|
315
|
+
if (kind === 0) {
|
|
316
|
+
// Bottle.
|
|
317
|
+
const bw = s * 0.26;
|
|
318
|
+
const bh = s * 0.52;
|
|
319
|
+
const bx = cx - bw / 2;
|
|
320
|
+
const by = floor - bh;
|
|
321
|
+
ctx.fillStyle = shade(bx, bx + bw, col);
|
|
322
|
+
ctx.beginPath();
|
|
323
|
+
roundRectPath(ctx, bx, by, bw, bh, bw * 0.28);
|
|
324
|
+
ctx.fill();
|
|
325
|
+
ctx.fillStyle = shade(cx - bw * 0.22, cx + bw * 0.22, darken(col, 0.5));
|
|
326
|
+
ctx.beginPath();
|
|
327
|
+
roundRectPath(ctx, cx - bw * 0.22, by - bh * 0.16, bw * 0.44, bh * 0.2, bw * 0.08);
|
|
328
|
+
ctx.fill();
|
|
329
|
+
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
|
|
330
|
+
ctx.beginPath();
|
|
331
|
+
roundRectPath(ctx, bx + bw * 0.18, by + bh * 0.42, bw * 0.64, bh * 0.22, bw * 0.06);
|
|
332
|
+
ctx.fill();
|
|
333
|
+
} else if (kind === 1) {
|
|
334
|
+
// Headphones.
|
|
335
|
+
const hw = s * 0.5;
|
|
336
|
+
ctx.lineCap = 'round';
|
|
337
|
+
ctx.lineWidth = s * 0.05;
|
|
338
|
+
ctx.strokeStyle = darken(col, 0.2);
|
|
339
|
+
ctx.beginPath();
|
|
340
|
+
ctx.arc(cx, floor - s * 0.28, hw * 0.46, Math.PI * 1.02, Math.PI * 1.98);
|
|
341
|
+
ctx.stroke();
|
|
342
|
+
for (const side of [-1, 1]) {
|
|
343
|
+
const ex = cx + side * hw * 0.46 - s * 0.075;
|
|
344
|
+
ctx.fillStyle = shade(ex, ex + s * 0.15, col);
|
|
345
|
+
ctx.beginPath();
|
|
346
|
+
roundRectPath(ctx, ex, floor - s * 0.36, s * 0.15, s * 0.24, s * 0.07);
|
|
347
|
+
ctx.fill();
|
|
348
|
+
}
|
|
349
|
+
} else if (kind === 2) {
|
|
350
|
+
// Watch.
|
|
351
|
+
const bw = s * 0.17;
|
|
352
|
+
ctx.fillStyle = shade(cx - bw / 2, cx + bw / 2, darken(col, 0.1));
|
|
353
|
+
ctx.beginPath();
|
|
354
|
+
roundRectPath(ctx, cx - bw / 2, floor - s * 0.62, bw, s * 0.6, bw * 0.4);
|
|
355
|
+
ctx.fill();
|
|
356
|
+
const cw = s * 0.28;
|
|
357
|
+
const cy = floor - s * 0.32;
|
|
358
|
+
ctx.fillStyle = shade(cx - cw / 2, cx + cw / 2, '#c9ccd3');
|
|
359
|
+
ctx.beginPath();
|
|
360
|
+
roundRectPath(ctx, cx - cw / 2, cy - cw * 0.6, cw, cw * 1.2, cw * 0.3);
|
|
361
|
+
ctx.fill();
|
|
362
|
+
ctx.fillStyle = '#0c0d10';
|
|
363
|
+
ctx.beginPath();
|
|
364
|
+
roundRectPath(ctx, cx - cw * 0.42, cy - cw * 0.5, cw * 0.84, cw, cw * 0.24);
|
|
365
|
+
ctx.fill();
|
|
366
|
+
ctx.lineWidth = cw * 0.07;
|
|
367
|
+
ctx.lineCap = 'round';
|
|
368
|
+
ctx.strokeStyle = col;
|
|
369
|
+
ctx.beginPath();
|
|
370
|
+
ctx.arc(cx, cy, cw * 0.26, -Math.PI / 2, Math.PI * 0.9);
|
|
371
|
+
ctx.stroke();
|
|
372
|
+
} else {
|
|
373
|
+
// Glossy speaker / sphere.
|
|
374
|
+
const rr = s * 0.24;
|
|
375
|
+
const cy = floor - rr;
|
|
376
|
+
ctx.fillStyle = radial(m, cx - rr * 0.35, cy - rr * 0.4, rr * 1.6, [
|
|
377
|
+
[0, lighten(col, 0.55)],
|
|
378
|
+
[0.45, col],
|
|
379
|
+
[1, darken(col, 0.45)],
|
|
380
|
+
]);
|
|
381
|
+
ctx.beginPath();
|
|
382
|
+
ctx.arc(cx, cy, rr, 0, Math.PI * 2);
|
|
383
|
+
ctx.fill();
|
|
384
|
+
}
|
|
385
|
+
ctx.restore();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const PIN_SPOTS: ReadonlyArray<readonly [number, number]> = [
|
|
389
|
+
[0.14, 0.3],
|
|
390
|
+
[0.84, 0.74],
|
|
391
|
+
[0.86, 0.22],
|
|
392
|
+
[0.48, 0.96],
|
|
393
|
+
];
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Stylised city map with water, parks, a road grid, a route and pins.
|
|
397
|
+
* `focus` is the part of the map left uncovered by floating UI; the route
|
|
398
|
+
* and pins stay inside it.
|
|
399
|
+
*/
|
|
400
|
+
export function mapArt(
|
|
401
|
+
m: Mk,
|
|
402
|
+
x: number,
|
|
403
|
+
y: number,
|
|
404
|
+
w: number,
|
|
405
|
+
h: number,
|
|
406
|
+
k: number,
|
|
407
|
+
pinIcons: readonly IconName[] = ['leaf', 'bag', 'star'],
|
|
408
|
+
focus: { x: number; y: number; w: number; h: number } = { x, y, w, h },
|
|
409
|
+
): void {
|
|
410
|
+
const ctx = m.ctx;
|
|
411
|
+
const t = m.t;
|
|
412
|
+
const rng = createRng(k * 2713 + 29);
|
|
413
|
+
const f = focus;
|
|
414
|
+
const land = t.dark ? '#1b1f27' : '#f1efe9';
|
|
415
|
+
const block = t.dark ? '#20252e' : '#e9e6de';
|
|
416
|
+
const park = t.dark ? '#1c3428' : '#d3eac0';
|
|
417
|
+
const water = t.dark ? '#0f2a45' : '#a9d3f2';
|
|
418
|
+
const road = t.dark ? '#2e3440' : '#ffffff';
|
|
419
|
+
const casing = t.dark ? '#151920' : '#dedbd2';
|
|
420
|
+
const major = t.dark ? '#4a4537' : '#fde6a0';
|
|
421
|
+
ctx.save();
|
|
422
|
+
ctx.beginPath();
|
|
423
|
+
ctx.rect(x, y, w, h);
|
|
424
|
+
ctx.clip();
|
|
425
|
+
ctx.fillStyle = land;
|
|
426
|
+
ctx.fillRect(x, y, w, h);
|
|
427
|
+
|
|
428
|
+
const u = Math.max(w, h);
|
|
429
|
+
// Blocks between minor roads, on a slightly rotated grid.
|
|
430
|
+
ctx.save();
|
|
431
|
+
ctx.translate(x + w / 2, y + h / 2);
|
|
432
|
+
ctx.rotate(-0.21);
|
|
433
|
+
const cell = u * 0.11;
|
|
434
|
+
ctx.fillStyle = block;
|
|
435
|
+
for (let gx = -6; gx <= 6; gx++) {
|
|
436
|
+
for (let gy = -7; gy <= 7; gy++) {
|
|
437
|
+
if (rng.chance(0.35)) continue;
|
|
438
|
+
ctx.beginPath();
|
|
439
|
+
roundRectPath(ctx, gx * cell + cell * 0.12, gy * cell + cell * 0.12, cell * 0.76, cell * 0.76, cell * 0.08);
|
|
440
|
+
ctx.fill();
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
ctx.strokeStyle = road;
|
|
444
|
+
ctx.lineWidth = u * 0.008;
|
|
445
|
+
ctx.beginPath();
|
|
446
|
+
for (let g = -7; g <= 7; g++) {
|
|
447
|
+
ctx.moveTo(g * cell, -u);
|
|
448
|
+
ctx.lineTo(g * cell, u);
|
|
449
|
+
ctx.moveTo(-u, g * cell);
|
|
450
|
+
ctx.lineTo(u, g * cell);
|
|
451
|
+
}
|
|
452
|
+
ctx.stroke();
|
|
453
|
+
ctx.restore();
|
|
454
|
+
|
|
455
|
+
// Parks.
|
|
456
|
+
for (let i = 0; i < 3; i++) {
|
|
457
|
+
const px = x + w * rng.range(0.05, 0.85);
|
|
458
|
+
const py = y + h * rng.range(0.1, 0.85);
|
|
459
|
+
const pr = u * rng.range(0.06, 0.12);
|
|
460
|
+
const pts: [number, number][] = [];
|
|
461
|
+
for (let j = 0; j < 7; j++) {
|
|
462
|
+
const a = (j / 6) * Math.PI * 2;
|
|
463
|
+
const rr = pr * rng.range(0.75, 1.15);
|
|
464
|
+
pts.push([px + Math.cos(a) * rr * 1.3, py + Math.sin(a) * rr]);
|
|
465
|
+
}
|
|
466
|
+
ctx.beginPath();
|
|
467
|
+
smoothPath(ctx, pts);
|
|
468
|
+
ctx.closePath();
|
|
469
|
+
ctx.fillStyle = park;
|
|
470
|
+
ctx.fill();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// A river crossing the map.
|
|
474
|
+
const flip = rng.chance(0.5);
|
|
475
|
+
const riverPts: [number, number][] = [];
|
|
476
|
+
for (let i = 0; i <= 5; i++) {
|
|
477
|
+
const ty = y - h * 0.05 + (h * 1.1 * i) / 5;
|
|
478
|
+
riverPts.push([x + w * (flip ? 0.78 : 0.18) + Math.sin(i * 1.3 + k) * w * 0.12, ty]);
|
|
479
|
+
}
|
|
480
|
+
ctx.lineCap = 'round';
|
|
481
|
+
ctx.lineJoin = 'round';
|
|
482
|
+
ctx.beginPath();
|
|
483
|
+
smoothPath(ctx, riverPts);
|
|
484
|
+
ctx.strokeStyle = water;
|
|
485
|
+
ctx.lineWidth = u * 0.07;
|
|
486
|
+
ctx.stroke();
|
|
487
|
+
|
|
488
|
+
// Major roads: two sweeping avenues with casings.
|
|
489
|
+
const avenues: [number, number][][] = [
|
|
490
|
+
[
|
|
491
|
+
[x - w * 0.1, y + h * rng.range(0.25, 0.4)],
|
|
492
|
+
[x + w * 0.35, y + h * rng.range(0.35, 0.5)],
|
|
493
|
+
[x + w * 0.7, y + h * rng.range(0.4, 0.55)],
|
|
494
|
+
[x + w * 1.1, y + h * rng.range(0.5, 0.65)],
|
|
495
|
+
],
|
|
496
|
+
[
|
|
497
|
+
[x + w * rng.range(0.4, 0.6), y - h * 0.05],
|
|
498
|
+
[x + w * rng.range(0.45, 0.65), y + h * 0.4],
|
|
499
|
+
[x + w * rng.range(0.3, 0.5), y + h * 0.75],
|
|
500
|
+
[x + w * rng.range(0.35, 0.55), y + h * 1.05],
|
|
501
|
+
],
|
|
502
|
+
];
|
|
503
|
+
for (const pts of avenues) {
|
|
504
|
+
ctx.beginPath();
|
|
505
|
+
smoothPath(ctx, pts);
|
|
506
|
+
ctx.strokeStyle = casing;
|
|
507
|
+
ctx.lineWidth = u * 0.03;
|
|
508
|
+
ctx.stroke();
|
|
509
|
+
ctx.strokeStyle = major;
|
|
510
|
+
ctx.lineWidth = u * 0.022;
|
|
511
|
+
ctx.stroke();
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Route from the user to the destination.
|
|
515
|
+
const start: [number, number] = [f.x + f.w * 0.26, f.y + f.h * 0.86];
|
|
516
|
+
const end: [number, number] = [f.x + f.w * 0.66, f.y + f.h * 0.34];
|
|
517
|
+
const route: [number, number][] = [start, [f.x + f.w * 0.33, f.y + f.h * 0.64], [f.x + f.w * 0.52, f.y + f.h * 0.6], [f.x + f.w * 0.6, f.y + f.h * 0.46], end];
|
|
518
|
+
ctx.beginPath();
|
|
519
|
+
smoothPath(ctx, route);
|
|
520
|
+
ctx.strokeStyle = '#ffffff';
|
|
521
|
+
ctx.lineWidth = u * 0.024;
|
|
522
|
+
ctx.stroke();
|
|
523
|
+
ctx.strokeStyle = t.accent;
|
|
524
|
+
ctx.lineWidth = u * 0.016;
|
|
525
|
+
ctx.stroke();
|
|
526
|
+
|
|
527
|
+
// Points of interest.
|
|
528
|
+
const pinD = u * 0.06;
|
|
529
|
+
for (let i = 0; i < pinIcons.length; i++) {
|
|
530
|
+
// Fixed spots clear of the route, with a little jitter so maps differ.
|
|
531
|
+
const [sx, sy] = PIN_SPOTS[i % PIN_SPOTS.length];
|
|
532
|
+
const px = f.x + f.w * (sx + rng.range(-0.04, 0.04));
|
|
533
|
+
const py = f.y + f.h * (sy + rng.range(-0.05, 0.05));
|
|
534
|
+
const c = t.accents[(i + 1) % 4];
|
|
535
|
+
circle(m, px, py, pinD * 0.62, '#ffffff');
|
|
536
|
+
circle(m, px, py, pinD * 0.52, c);
|
|
537
|
+
icon(ctx, pinIcons[i], px, py, pinD * 0.56, '#ffffff', false, 2.4);
|
|
538
|
+
}
|
|
539
|
+
// Destination pin.
|
|
540
|
+
const [ex, ey] = end;
|
|
541
|
+
const pr = u * 0.045;
|
|
542
|
+
ctx.fillStyle = t.accent;
|
|
543
|
+
ctx.beginPath();
|
|
544
|
+
ctx.moveTo(ex, ey);
|
|
545
|
+
ctx.bezierCurveTo(ex - pr * 0.3, ey - pr * 0.8, ex - pr, ey - pr * 1.25, ex - pr, ey - pr * 2);
|
|
546
|
+
ctx.arc(ex, ey - pr * 2, pr, Math.PI, 0);
|
|
547
|
+
ctx.bezierCurveTo(ex + pr, ey - pr * 1.25, ex + pr * 0.3, ey - pr * 0.8, ex, ey);
|
|
548
|
+
ctx.closePath();
|
|
549
|
+
ctx.fill();
|
|
550
|
+
circle(m, ex, ey - pr * 2, pr * 0.42, '#ffffff');
|
|
551
|
+
// User location.
|
|
552
|
+
const [ux, uy] = start;
|
|
553
|
+
circle(m, ux, uy, u * 0.05, rgba('#3b82f6', 0.18));
|
|
554
|
+
circle(m, ux, uy, u * 0.019, '#ffffff');
|
|
555
|
+
circle(m, ux, uy, u * 0.014, '#3b82f6');
|
|
556
|
+
ctx.restore();
|
|
557
|
+
}
|