@massif-maps/style-tools 6.1.0-rc.1
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/README.md +58 -0
- package/dist/cli.js +436 -0
- package/dist/cli.js.map +1 -0
- package/dist/generated/properties.json +2488 -0
- package/dist/legend-svg.js +142 -0
- package/dist/legend-svg.js.map +1 -0
- package/dist/mapbox2css/browser-host.js +27 -0
- package/dist/mapbox2css/browser-host.js.map +1 -0
- package/dist/mapbox2css/casing.js +84 -0
- package/dist/mapbox2css/casing.js.map +1 -0
- package/dist/mapbox2css/config.js +212 -0
- package/dist/mapbox2css/config.js.map +1 -0
- package/dist/mapbox2css/contour.js +61 -0
- package/dist/mapbox2css/contour.js.map +1 -0
- package/dist/mapbox2css/coverage.js +56 -0
- package/dist/mapbox2css/coverage.js.map +1 -0
- package/dist/mapbox2css/emissive.js +192 -0
- package/dist/mapbox2css/emissive.js.map +1 -0
- package/dist/mapbox2css/expression.js +606 -0
- package/dist/mapbox2css/expression.js.map +1 -0
- package/dist/mapbox2css/filter.js +285 -0
- package/dist/mapbox2css/filter.js.map +1 -0
- package/dist/mapbox2css/fold.js +588 -0
- package/dist/mapbox2css/fold.js.map +1 -0
- package/dist/mapbox2css/host.js +13 -0
- package/dist/mapbox2css/host.js.map +1 -0
- package/dist/mapbox2css/index.js +3599 -0
- package/dist/mapbox2css/index.js.map +1 -0
- package/dist/mapbox2css/narrow.js +284 -0
- package/dist/mapbox2css/narrow.js.map +1 -0
- package/dist/mapbox2css/node-host.js +36 -0
- package/dist/mapbox2css/node-host.js.map +1 -0
- package/dist/mapbox2css/placement.js +62 -0
- package/dist/mapbox2css/placement.js.map +1 -0
- package/dist/mapbox2css/png.js +264 -0
- package/dist/mapbox2css/png.js.map +1 -0
- package/dist/mapbox2css/properties.js +155 -0
- package/dist/mapbox2css/properties.js.map +1 -0
- package/dist/mapbox2css/schema.js +865 -0
- package/dist/mapbox2css/schema.js.map +1 -0
- package/dist/mapbox2css/shield.js +118 -0
- package/dist/mapbox2css/shield.js.map +1 -0
- package/dist/mapbox2css/split.js +380 -0
- package/dist/mapbox2css/split.js.map +1 -0
- package/dist/mapbox2css/sprite.js +882 -0
- package/dist/mapbox2css/sprite.js.map +1 -0
- package/dist/mapbox2css/types.js +2 -0
- package/dist/mapbox2css/types.js.map +1 -0
- package/dist/mapbox2css/variables.js +273 -0
- package/dist/mapbox2css/variables.js.map +1 -0
- package/dist/wasm.js +43 -0
- package/dist/wasm.js.map +1 -0
- package/package.json +48 -0
- package/wasm/massif-style.mjs +2 -0
- package/wasm/massif-style.wasm +0 -0
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
import { createImage } from './host.js';
|
|
2
|
+
/*
|
|
3
|
+
* Everything below reads bytes, decodes PNGs and writes files through the host - so this module
|
|
4
|
+
* imports no `node:` builtin and no pngjs, and the whole converter bundles for a browser. The CLI
|
|
5
|
+
* installs nodeSpriteHost; the website's style preview installs one over fetch and a canvas.
|
|
6
|
+
*/
|
|
7
|
+
let host = null;
|
|
8
|
+
export function setSpriteHost(spriteHost) {
|
|
9
|
+
host = spriteHost;
|
|
10
|
+
}
|
|
11
|
+
function requireHost() {
|
|
12
|
+
if (!host)
|
|
13
|
+
throw new Error('No sprite host installed - call setSpriteHost() first');
|
|
14
|
+
return host;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* MapBox stores an SDF sprite's distance field in the ALPHA channel; the SDK's label shader reads
|
|
18
|
+
* it from RED. Same field, different channel AND a different encoding, so it is re-encoded rather
|
|
19
|
+
* than copied - the SDK then scales it like a glyph, which is what keeps a zoom-driven size crisp
|
|
20
|
+
* and lets marker-halo-radius grow a halo from it.
|
|
21
|
+
*
|
|
22
|
+
* The two conventions:
|
|
23
|
+
*
|
|
24
|
+
* | | edge at | per texel |
|
|
25
|
+
* |---|---|---|
|
|
26
|
+
* | MapBox (tiny-sdf, cutoff 0.25, radius 8) | 0.75 | 1/8 |
|
|
27
|
+
* | Massif (`BitmapCanvas::drawSDFPixel`, `128 / BITMAP_SDF_SCALE`) | 0.5 | 1/16 |
|
|
28
|
+
*
|
|
29
|
+
* Copied straight across, the SDK read MapBox's 0.75 edge as four texels INSIDE the shape: the
|
|
30
|
+
* hole in `circle-dot` filled in and every icon came out fat and solid, with a halo squeezed to a
|
|
31
|
+
* quarter of its width. Signed distance in texels is `(v - 0.75) * 8` and the SDK wants
|
|
32
|
+
* `d * 16 + 127.5`, which is the one line below.
|
|
33
|
+
*
|
|
34
|
+
* Flattening instead (--sdf-flatten) is the fallback for an SDK without marker-sdf: a smoothstep
|
|
35
|
+
* around the same edge, written WHITE so marker-color still tints it. That loses the scaling and
|
|
36
|
+
* the halo, which is the whole reason not to do it by default.
|
|
37
|
+
*/
|
|
38
|
+
const SDF_EDGE = 0.75;
|
|
39
|
+
const SDF_GAMMA = 0.08;
|
|
40
|
+
const MAPBOX_SDF_RADIUS = 8;
|
|
41
|
+
const MASSIF_SDF_UNIT = 128 / 8; // BITMAP_SDF_SCALE
|
|
42
|
+
const MASSIF_SDF_EDGE = 127.5;
|
|
43
|
+
// What the SDK's own encoding can hold: 127.5 / 16 texels either side of the edge.
|
|
44
|
+
const FIELD_LIMIT = 127.5 / 16;
|
|
45
|
+
/**
|
|
46
|
+
* One MapBox distance-field byte in the SDK's own encoding.
|
|
47
|
+
*
|
|
48
|
+
* `scale` is the resample the icon has already been through: MAPBOX_SDF_RADIUS counts texels of the
|
|
49
|
+
* ORIGINAL sprite, and one texel of a 0.4-scaled icon covers 2.5 of them. Left out, the field
|
|
50
|
+
* claimed a spread 1/scale times wider than it had, so the smaller the icon the shallower its
|
|
51
|
+
* gradient - blurred edges, and a halo that ran past the field into a square patch.
|
|
52
|
+
*/
|
|
53
|
+
function reencodeSdf(alpha, scale) {
|
|
54
|
+
const texels = (alpha / 255 - SDF_EDGE) * MAPBOX_SDF_RADIUS * scale;
|
|
55
|
+
return Math.max(0, Math.min(255, Math.round(texels * MASSIF_SDF_UNIT + MASSIF_SDF_EDGE)));
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* `mapbox://sprites/<user>/<style>[/<hash>]` -> the API URL that actually serves it. Mapbox styles
|
|
59
|
+
* name their sheet with their own scheme, which is not a URL anything can fetch; the trailing hash
|
|
60
|
+
* is a cache token and the path without it serves the same sheet.
|
|
61
|
+
*/
|
|
62
|
+
export function resolveSpriteUrl(url) {
|
|
63
|
+
const mapbox = /^mapbox:\/\/sprites\/([^/]+)\/([^/]+)/.exec(url);
|
|
64
|
+
return mapbox ? `https://api.mapbox.com/styles/v1/${mapbox[1]}/${mapbox[2]}/sprite` : url;
|
|
65
|
+
}
|
|
66
|
+
function resolveSpriteUrls(style) {
|
|
67
|
+
const out = new Map();
|
|
68
|
+
const sprite = style.sprite;
|
|
69
|
+
if (typeof sprite === 'string') {
|
|
70
|
+
out.set('default', resolveSpriteUrl(sprite));
|
|
71
|
+
}
|
|
72
|
+
else if (Array.isArray(sprite)) {
|
|
73
|
+
for (const entry of sprite) {
|
|
74
|
+
const record = entry;
|
|
75
|
+
if (typeof record.id === 'string' && typeof record.url === 'string') {
|
|
76
|
+
out.set(record.id, resolveSpriteUrl(record.url));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
/** Appends the style's own query string (MapTiler carries its key there) to the sprite URLs. */
|
|
83
|
+
function withQuery(base, suffix) {
|
|
84
|
+
const q = base.indexOf('?');
|
|
85
|
+
return q === -1 ? base + suffix : base.slice(0, q) + suffix + base.slice(q);
|
|
86
|
+
}
|
|
87
|
+
export async function loadSprites(style, keySuffix) {
|
|
88
|
+
const sheets = new Map();
|
|
89
|
+
const spriteHost = requireHost();
|
|
90
|
+
for (const [id, base] of resolveSpriteUrls(style)) {
|
|
91
|
+
// The DENSEST sheet the provider serves, tried in order. A sprite is upscaled by the
|
|
92
|
+
// display's pixel ratio before it reaches the screen - about 2.6x on the device this was
|
|
93
|
+
// measured on - and a traffic light drawn from 19 texels reads as a smudge. pixelRatio in
|
|
94
|
+
// the index is what puts it back to its logical size, so the only cost is size.
|
|
95
|
+
//
|
|
96
|
+
// MapBox serves up to @4x, and a POI icon at 80 texels rather than 40 is what carries the
|
|
97
|
+
// notches between a fork's tines through the distance field: at @2x they are one texel of
|
|
98
|
+
// partial coverage and close up at the size the icon is drawn. MapTiler stops at @2x, so
|
|
99
|
+
// the loop just falls through for it.
|
|
100
|
+
//
|
|
101
|
+
// The variant belongs to the PATH, before the key: appended after it the URL reads
|
|
102
|
+
// `sprite.json?access_token=pk...@2x`, which asks for the 1x sheet with a corrupt token,
|
|
103
|
+
// fails, and falls back to 1x without a word. Every sprite in a keyed style was blurry.
|
|
104
|
+
let index;
|
|
105
|
+
let image;
|
|
106
|
+
for (const variant of ['@4x', '@3x', '@2x', '']) {
|
|
107
|
+
const url = base + variant + keySuffix;
|
|
108
|
+
try {
|
|
109
|
+
const json = await spriteHost.fetchBytes(withQuery(url, '.json'));
|
|
110
|
+
index = JSON.parse(new TextDecoder().decode(json));
|
|
111
|
+
image = await spriteHost.decodePng(await spriteHost.fetchBytes(withQuery(url, '.png')));
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
index = undefined;
|
|
116
|
+
image = undefined;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!index || !image)
|
|
120
|
+
throw new Error(`no sprite sheet at ${base}`);
|
|
121
|
+
sheets.set(id, { index, image });
|
|
122
|
+
}
|
|
123
|
+
return sheets;
|
|
124
|
+
}
|
|
125
|
+
/** 'misc:foo' picks the 'misc' sheet; a bare name is the default one. */
|
|
126
|
+
function splitIconName(name) {
|
|
127
|
+
const colon = name.indexOf(':');
|
|
128
|
+
return colon === -1 ? ['default', name] : [name.slice(0, colon), name.slice(colon + 1)];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Cuts one icon out of its sheet and writes it as its own PNG. Returns null when the sheet has no
|
|
132
|
+
* such icon, which a style referencing an image it never ships does produce.
|
|
133
|
+
*/
|
|
134
|
+
export function extractIcon(sprites, name, outDir, flattenSdf = false, tint, scale = 1,
|
|
135
|
+
/** The file name to write under, when it is not the icon's own qualified one. */
|
|
136
|
+
writeAs) {
|
|
137
|
+
const [sheetId, iconName] = splitIconName(name);
|
|
138
|
+
const sheet = sprites.get(sheetId);
|
|
139
|
+
const entry = sheet?.index[iconName];
|
|
140
|
+
if (!sheet || !entry || entry.width < 1 || entry.height < 1)
|
|
141
|
+
return null;
|
|
142
|
+
// A shield's image is a plain bitmap - ShieldSymbolizer has no `sdf` - so an SDF sprite going
|
|
143
|
+
// into one is resolved here AND given the colour the style would have tinted it with.
|
|
144
|
+
const flatten = flattenSdf || tint !== undefined;
|
|
145
|
+
const colour = tint ?? [255, 255, 255];
|
|
146
|
+
// The DISTANCE FIELD is carried through the resample and only turned into pixels afterwards.
|
|
147
|
+
// A field is a smooth signal and scales cleanly; flattening first and scaling the result
|
|
148
|
+
// resamples an already-antialiased edge, which is what made a shrunk icon look soft.
|
|
149
|
+
const icon = createImage(entry.width, entry.height);
|
|
150
|
+
for (let y = 0; y < entry.height; y++) {
|
|
151
|
+
for (let x = 0; x < entry.width; x++) {
|
|
152
|
+
const src = ((entry.y + y) * sheet.image.width + (entry.x + x)) * 4;
|
|
153
|
+
const dst = (y * entry.width + x) * 4;
|
|
154
|
+
if (entry.sdf) {
|
|
155
|
+
const distance = sheet.image.data[src + 3];
|
|
156
|
+
icon.data[dst] = icon.data[dst + 1] = icon.data[dst + 2] = distance;
|
|
157
|
+
icon.data[dst + 3] = distance;
|
|
158
|
+
}
|
|
159
|
+
else {
|
|
160
|
+
icon.data.set(sheet.image.data.subarray(src, src + 4), dst);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// A shield draws its image at the bitmap's own size, so icon-size is baked in here instead.
|
|
165
|
+
const scaled = scale === 1 ? icon : resample(icon, scale);
|
|
166
|
+
let out = scaled;
|
|
167
|
+
let padding = 0;
|
|
168
|
+
if (entry.sdf) {
|
|
169
|
+
for (let i = 0; i < scaled.data.length; i += 4) {
|
|
170
|
+
const distance = scaled.data[i + 3];
|
|
171
|
+
if (flatten) {
|
|
172
|
+
const coverage = smoothstep(SDF_EDGE - SDF_GAMMA, SDF_EDGE + SDF_GAMMA, distance / 255);
|
|
173
|
+
[scaled.data[i], scaled.data[i + 1], scaled.data[i + 2]] = colour;
|
|
174
|
+
scaled.data[i + 3] = Math.round(coverage * 255);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
// Alpha -> red, re-encoded, and opaque: the shader's `color.r` IS the field.
|
|
178
|
+
const value = reencodeSdf(distance, scale);
|
|
179
|
+
scaled.data[i] = scaled.data[i + 1] = scaled.data[i + 2] = value;
|
|
180
|
+
scaled.data[i + 3] = 255;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if (!flatten) {
|
|
184
|
+
const ratio = entry.pixelRatio && entry.pixelRatio > 0 ? entry.pixelRatio : 1;
|
|
185
|
+
out = padField(scaled, ratio);
|
|
186
|
+
padding = (out.height - scaled.height) / 2 / ratio;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Tint and scale are baked in, so one sprite drawn two ways is two files.
|
|
190
|
+
const suffix = [
|
|
191
|
+
tint ? tint.map((c) => c.toString(16).padStart(2, '0')).join('') : '',
|
|
192
|
+
scale === 1 ? '' : `x${Math.round(scale * 100)}`,
|
|
193
|
+
].filter(Boolean).join('-');
|
|
194
|
+
const file = `${safeFileName(writeAs ?? name)}${suffix ? `-${suffix}` : ''}.png`;
|
|
195
|
+
const spriteHost = requireHost();
|
|
196
|
+
spriteHost.writeIcon(outDir, `icons/${file}`, spriteHost.encodePng(out, ICON_PNG));
|
|
197
|
+
const ratio = entry.pixelRatio && entry.pixelRatio > 0 ? entry.pixelRatio : 1;
|
|
198
|
+
return {
|
|
199
|
+
file: `icons/${file}`,
|
|
200
|
+
width: out.width / ratio,
|
|
201
|
+
height: out.height / ratio,
|
|
202
|
+
sdf: !!entry.sdf && !flatten,
|
|
203
|
+
pixelRatio: ratio,
|
|
204
|
+
padding,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
/** Alpha at which a texel counts as one of the icon's flat colours rather than an edge. */
|
|
208
|
+
const FLAT_ALPHA = 200;
|
|
209
|
+
/**
|
|
210
|
+
* How far in from the silhouette the ring is looked for, in texels. A transit roundel's ring IS the
|
|
211
|
+
* mark and is drawn several texels thick, so a cap of 3 measured it as 3 and every roundel came out
|
|
212
|
+
* with a poi disc's hairline. Bounded all the same: past this the band is the disc, not a ring.
|
|
213
|
+
*/
|
|
214
|
+
const RING_DEPTH = 8;
|
|
215
|
+
/** Where the plate fields are written, so they never collide with the raster cut of the same name. */
|
|
216
|
+
const GLYPH_DIR = 'icons-glyph';
|
|
217
|
+
/**
|
|
218
|
+
* How the icons are encoded. Greyscale is LOSSLESS here - the pixels the SDK reads are identical -
|
|
219
|
+
* and it halves the sprite, which is most of what a converted style weighs (3.9 MB of PNG for
|
|
220
|
+
* Mapbox Standard's 595 icons, 2.0 MB after).
|
|
221
|
+
*
|
|
222
|
+
* A distance FIELD carries its value in one channel: buildField writes r=g=b and a fully opaque
|
|
223
|
+
* alpha, so three of the four bytes per pixel are a copy and a constant. One channel is written
|
|
224
|
+
* instead, and a decoder expands it right back to r=g=b=v, a=255. A host that cannot encode
|
|
225
|
+
* greyscale writes RGBA and is only bigger.
|
|
226
|
+
*/
|
|
227
|
+
const FIELD_PNG = { greyscale: true };
|
|
228
|
+
/** A colour icon needs all four channels. */
|
|
229
|
+
const ICON_PNG = {};
|
|
230
|
+
function colourDistance(a, b) {
|
|
231
|
+
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* A signed distance field over a coverage grid, in the SDK's encoding.
|
|
235
|
+
*
|
|
236
|
+
* A thresholded mask puts the edge half a texel out from the cell it measured, so the EDT only says
|
|
237
|
+
* where the field is DEEP; anywhere the artwork carries partial coverage the coverage itself is the
|
|
238
|
+
* answer, since it is the only record of where inside the texel the edge fell. The threshold is not
|
|
239
|
+
* allowed to decide on its own either: a stroke thinner than a texel never reaches 1.0 anywhere and
|
|
240
|
+
* thresholding it at 0.5 erased it outright - a bicycle's spokes came out as a handful of dots.
|
|
241
|
+
*/
|
|
242
|
+
function buildField(width, height, cell) {
|
|
243
|
+
const field = createImage(width, height);
|
|
244
|
+
const ins = new Float64Array(width * height);
|
|
245
|
+
const outs = new Float64Array(width * height);
|
|
246
|
+
for (let y = 0; y < height; y++) {
|
|
247
|
+
for (let x = 0; x < width; x++) {
|
|
248
|
+
const isInk = cell(x, y) >= 0.5;
|
|
249
|
+
ins[y * width + x] = isInk ? 1e20 : 0;
|
|
250
|
+
outs[y * width + x] = isInk ? 0 : 1e20;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
edt(ins, width, height);
|
|
254
|
+
edt(outs, width, height);
|
|
255
|
+
for (let y = 0; y < height; y++) {
|
|
256
|
+
for (let x = 0; x < width; x++) {
|
|
257
|
+
const i = y * width + x;
|
|
258
|
+
const partial = cell(x, y) > 0 && cell(x, y) < 1;
|
|
259
|
+
const raw = Math.sqrt(ins[i]) - Math.sqrt(outs[i]);
|
|
260
|
+
const base = raw - Math.sign(raw) * 0.5;
|
|
261
|
+
const signed = partial || Math.abs(base) <= 0.5 ? cell(x, y) - 0.5 : base;
|
|
262
|
+
const texels = Math.max(-FIELD_LIMIT, Math.min(FIELD_LIMIT, signed));
|
|
263
|
+
const value = Math.max(0, Math.min(255, Math.round(MASSIF_SDF_EDGE + texels * MASSIF_SDF_UNIT)));
|
|
264
|
+
const dst = i * 4;
|
|
265
|
+
field.data[dst] = field.data[dst + 1] = field.data[dst + 2] = value;
|
|
266
|
+
field.data[dst + 3] = 255;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return field;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* The GLYPH of a composite vector icon as a distance field, and the disc it sat on.
|
|
273
|
+
*
|
|
274
|
+
* MapBox's sheet ships ONE flat render of an `["image", name, { params }]` icon, with the icon's own
|
|
275
|
+
* default colours baked into it: a disc, a ring around it, the glyph, and their blends. Drawn as it
|
|
276
|
+
* comes, every POI icon is grey where mapbox tints it by class, and a distance field taken from it
|
|
277
|
+
* is nonsense - the red channel of a blue disc reads as "outside" and the white glyph as "inside",
|
|
278
|
+
* which drew the icons inverted.
|
|
279
|
+
*
|
|
280
|
+
* Split in two, neither half is baked: the glyph becomes a field the style colours per feature, and
|
|
281
|
+
* the disc becomes the shield's icon PLATE, whose fill, border and radius are style properties. The
|
|
282
|
+
* ring is cropped off the field so the plate's border sits exactly where the artwork's did.
|
|
283
|
+
*
|
|
284
|
+
* Returns null for anything that is not one of these - a plain single-colour sprite, or a real SDF.
|
|
285
|
+
*/
|
|
286
|
+
export function extractIconPlate(sprites, name, outDir, writeAs) {
|
|
287
|
+
const [sheetId, iconName] = splitIconName(name);
|
|
288
|
+
const sheet = sprites.get(sheetId);
|
|
289
|
+
const entry = sheet?.index[iconName];
|
|
290
|
+
if (!sheet || !entry || entry.sdf || entry.width < 8 || entry.height < 8)
|
|
291
|
+
return null;
|
|
292
|
+
const width = entry.width;
|
|
293
|
+
const height = entry.height;
|
|
294
|
+
const at = (x, y) => {
|
|
295
|
+
const i = ((entry.y + y) * sheet.image.width + (entry.x + x)) * 4;
|
|
296
|
+
const d = sheet.image.data;
|
|
297
|
+
return [d[i], d[i + 1], d[i + 2], d[i + 3]];
|
|
298
|
+
};
|
|
299
|
+
// Distance from the transparent surround, so the ring can be found whatever shape the icon is:
|
|
300
|
+
// a POI disc reaches the cell edge on the axes and a transit roundel at its corners, and one
|
|
301
|
+
// radius threshold cannot describe both. The grid is bordered so a silhouette touching the cell
|
|
302
|
+
// still has an outside to measure from.
|
|
303
|
+
const gw = width + 2;
|
|
304
|
+
const gh = height + 2;
|
|
305
|
+
const outward = new Float64Array(gw * gh);
|
|
306
|
+
for (let y = 0; y < gh; y++) {
|
|
307
|
+
for (let x = 0; x < gw; x++) {
|
|
308
|
+
const inside = x > 0 && y > 0 && x <= width && y <= height && at(x - 1, y - 1)[3] >= FLAT_ALPHA;
|
|
309
|
+
outward[y * gw + x] = inside ? 1e20 : 0;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
edt(outward, gw, gh);
|
|
313
|
+
const depth = (x, y) => Math.sqrt(outward[(y + 1) * gw + (x + 1)]);
|
|
314
|
+
// The flat colours, by how much of the icon they cover. Their blends are the rest.
|
|
315
|
+
const counts = new Map();
|
|
316
|
+
let flats = 0;
|
|
317
|
+
for (let y = 0; y < height; y++) {
|
|
318
|
+
for (let x = 0; x < width; x++) {
|
|
319
|
+
const [r, g, b, a] = at(x, y);
|
|
320
|
+
if (a < FLAT_ALPHA)
|
|
321
|
+
continue;
|
|
322
|
+
flats++;
|
|
323
|
+
const key = `${r},${g},${b}`;
|
|
324
|
+
const seen = counts.get(key);
|
|
325
|
+
if (seen)
|
|
326
|
+
seen.count++;
|
|
327
|
+
else
|
|
328
|
+
counts.set(key, { colour: [r, g, b], count: 1 });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (flats < 32)
|
|
332
|
+
return null;
|
|
333
|
+
const palette = [...counts.values()].filter((c) => c.count >= flats * 0.015).map((c) => c.colour);
|
|
334
|
+
if (palette.length < 3)
|
|
335
|
+
return null;
|
|
336
|
+
const dominant = (accept, exclude) => {
|
|
337
|
+
const tally = new Map();
|
|
338
|
+
for (let y = 0; y < height; y++) {
|
|
339
|
+
for (let x = 0; x < width; x++) {
|
|
340
|
+
const [r, g, b, a] = at(x, y);
|
|
341
|
+
if (a < FLAT_ALPHA || !accept(x, y))
|
|
342
|
+
continue;
|
|
343
|
+
const colour = [r, g, b];
|
|
344
|
+
if (!palette.some((p) => colourDistance(p, colour) === 0))
|
|
345
|
+
continue;
|
|
346
|
+
if (exclude.some((e) => colourDistance(e, colour) === 0))
|
|
347
|
+
continue;
|
|
348
|
+
const key = `${r},${g},${b}`;
|
|
349
|
+
const seen = tally.get(key);
|
|
350
|
+
if (seen)
|
|
351
|
+
seen.count++;
|
|
352
|
+
else
|
|
353
|
+
tally.set(key, { colour, count: 1 });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
let best = null;
|
|
357
|
+
for (const item of tally.values())
|
|
358
|
+
if (!best || item.count > best.count)
|
|
359
|
+
best = item;
|
|
360
|
+
return best ? best.colour : null;
|
|
361
|
+
};
|
|
362
|
+
// The OUTERMOST texel row, which is the ring wherever there is one: a wider band is already
|
|
363
|
+
// mostly disc on an icon whose two flats meet without an antialiased row between them.
|
|
364
|
+
const ring = dominant((x, y) => depth(x, y) <= 1, []);
|
|
365
|
+
if (!ring)
|
|
366
|
+
return null;
|
|
367
|
+
// How thick the ring actually is: the band is followed inward while it still dominates.
|
|
368
|
+
let ringTexels = 0;
|
|
369
|
+
while (ringTexels < RING_DEPTH) {
|
|
370
|
+
const band = dominant((x, y) => depth(x, y) > ringTexels && depth(x, y) <= ringTexels + 1, []);
|
|
371
|
+
if (!band || colourDistance(band, ring) > 0)
|
|
372
|
+
break;
|
|
373
|
+
ringTexels++;
|
|
374
|
+
}
|
|
375
|
+
if (ringTexels === 0)
|
|
376
|
+
return null;
|
|
377
|
+
const inner = (x, y) => depth(x, y) > ringTexels;
|
|
378
|
+
const disc = dominant(inner, [ring]);
|
|
379
|
+
if (!disc)
|
|
380
|
+
return null;
|
|
381
|
+
// What is left inside the disc, ANTIALIASING discounted. A blend of the disc and the ring lies
|
|
382
|
+
// on the segment between them, and a 32-texel roundel carries more of it than it does glyph -
|
|
383
|
+
// taken for a flat it won the count, and the icon drew as a handful of specks.
|
|
384
|
+
const discToRing = colourDistance(disc, ring);
|
|
385
|
+
const isBlend = (c) => discToRing > 0
|
|
386
|
+
&& colourDistance(c, disc) + colourDistance(c, ring) <= discToRing * 1.15;
|
|
387
|
+
const candidates = palette.filter((c) => colourDistance(c, disc) > 0
|
|
388
|
+
&& colourDistance(c, ring) > 0 && !isBlend(c));
|
|
389
|
+
if (candidates.length === 0)
|
|
390
|
+
return null;
|
|
391
|
+
// MapBox composes an icon as `icon-stroke` under `icon`, so the glyph's own colour is the one
|
|
392
|
+
// the other ENCLOSES - measured as the mean distance from the disc, since the two are parted by
|
|
393
|
+
// an antialiased row and neither touches it. Taken by pixel count instead, an outlined glyph
|
|
394
|
+
// gave its OUTLINE: the ⓘ drew as a white ring with the disc showing through the middle, which
|
|
395
|
+
// is what "no white in the centre" was.
|
|
396
|
+
const fromDisc = new Float64Array(width * height);
|
|
397
|
+
for (let y = 0; y < height; y++) {
|
|
398
|
+
for (let x = 0; x < width; x++) {
|
|
399
|
+
const [r, g, b, a] = at(x, y);
|
|
400
|
+
const onDisc = a >= FLAT_ALPHA && colourDistance([r, g, b], disc) === 0;
|
|
401
|
+
fromDisc[y * width + x] = onDisc ? 0 : 1e20;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
edt(fromDisc, width, height);
|
|
405
|
+
let ink = null;
|
|
406
|
+
let deepest = -1;
|
|
407
|
+
for (const candidate of candidates) {
|
|
408
|
+
let total = 0;
|
|
409
|
+
let count = 0;
|
|
410
|
+
for (let y = 0; y < height; y++) {
|
|
411
|
+
for (let x = 0; x < width; x++) {
|
|
412
|
+
const [r, g, b, a] = at(x, y);
|
|
413
|
+
if (a < FLAT_ALPHA || !inner(x, y) || colourDistance([r, g, b], candidate) > 0)
|
|
414
|
+
continue;
|
|
415
|
+
total += Math.sqrt(fromDisc[y * width + x]);
|
|
416
|
+
count++;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
if (count === 0)
|
|
420
|
+
continue;
|
|
421
|
+
const mean = total / count;
|
|
422
|
+
if (mean > deepest) {
|
|
423
|
+
deepest = mean;
|
|
424
|
+
ink = candidate;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if (!ink)
|
|
428
|
+
return null;
|
|
429
|
+
// MapBox's `icon-stroke` - the outline it draws UNDER the glyph - is the candidate the ink is
|
|
430
|
+
// not, and how far it reaches past the ink is what the SDK's icon halo has to grow.
|
|
431
|
+
let strokeWidth = 0;
|
|
432
|
+
const stroke = candidates.find((c) => colourDistance(c, ink) > 0) ?? null;
|
|
433
|
+
if (stroke) {
|
|
434
|
+
const fromInk = new Float64Array(width * height);
|
|
435
|
+
for (let y = 0; y < height; y++) {
|
|
436
|
+
for (let x = 0; x < width; x++) {
|
|
437
|
+
const [r, g, b, a] = at(x, y);
|
|
438
|
+
const onInk = a >= FLAT_ALPHA && colourDistance([r, g, b], ink) === 0;
|
|
439
|
+
fromInk[y * width + x] = onInk ? 0 : 1e20;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
edt(fromInk, width, height);
|
|
443
|
+
for (let y = 0; y < height; y++) {
|
|
444
|
+
for (let x = 0; x < width; x++) {
|
|
445
|
+
const [r, g, b, a] = at(x, y);
|
|
446
|
+
if (a < FLAT_ALPHA || !inner(x, y) || colourDistance([r, g, b], stroke) > 0)
|
|
447
|
+
continue;
|
|
448
|
+
strokeWidth = Math.max(strokeWidth, Math.min(3, Math.sqrt(fromInk[y * width + x])));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// How much INK a texel holds: its position along the disc -> ink axis. A partly covered texel
|
|
453
|
+
// is a linear blend of the two, so the projection IS the coverage, and it needs no list of what
|
|
454
|
+
// else the icon is painted with.
|
|
455
|
+
//
|
|
456
|
+
// Snapping to the nearest flat instead cost the thin strokes: a bicycle's spokes never reach
|
|
457
|
+
// the ink colour anywhere, every one of their texels is a blend, and on a blue roundel that
|
|
458
|
+
// blend is also a blend of the disc and the ring - so each was read as "not ink" and the wheels
|
|
459
|
+
// drew as a ring of dots.
|
|
460
|
+
const axis = [ink[0] - disc[0], ink[1] - disc[1], ink[2] - disc[2]];
|
|
461
|
+
const axisLength2 = axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2];
|
|
462
|
+
const coverage = (x, y) => {
|
|
463
|
+
const [r, g, b, a] = at(x, y);
|
|
464
|
+
// The ring is not part of the glyph, and the disc's own box catches its corners. A texel of
|
|
465
|
+
// margin past the ring, because a light ring projects high on this axis and a glyph never
|
|
466
|
+
// reaches the disc's edge anyway - without it every roundel kept four white corner specks.
|
|
467
|
+
if (a < FLAT_ALPHA || depth(x, y) <= ringTexels + 1 || axisLength2 === 0)
|
|
468
|
+
return 0;
|
|
469
|
+
const t = ((r - disc[0]) * axis[0] + (g - disc[1]) * axis[1] + (b - disc[2]) * axis[2]) / axisLength2;
|
|
470
|
+
return Math.max(0, Math.min(1, t));
|
|
471
|
+
};
|
|
472
|
+
// The disc's own box and area: a rounded rect of side S with corner radius r covers
|
|
473
|
+
// S^2 - (4 - pi) r^2, which is the radius without fitting an arc to a 40-texel shape.
|
|
474
|
+
let x0 = width, y0 = height, x1 = -1, y1 = -1, area = 0;
|
|
475
|
+
for (let y = 0; y < height; y++) {
|
|
476
|
+
for (let x = 0; x < width; x++) {
|
|
477
|
+
if (!(at(x, y)[3] >= FLAT_ALPHA && inner(x, y)))
|
|
478
|
+
continue;
|
|
479
|
+
area++;
|
|
480
|
+
x0 = Math.min(x0, x);
|
|
481
|
+
y0 = Math.min(y0, y);
|
|
482
|
+
x1 = Math.max(x1, x);
|
|
483
|
+
y1 = Math.max(y1, y);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
if (x1 < x0 || y1 < y0)
|
|
487
|
+
return null;
|
|
488
|
+
const discW = x1 - x0 + 1;
|
|
489
|
+
const discH = y1 - y0 + 1;
|
|
490
|
+
// A plate is a rounded RECT fitted to the icon's box, so it stands in for a disc or a roundel
|
|
491
|
+
// and for nothing else. MapBox's generic pin splits into flats just as cleanly and came out as
|
|
492
|
+
// a coloured pill with its glyph knocked out.
|
|
493
|
+
if (Math.abs(discW - discH) > 0.15 * Math.max(discW, discH))
|
|
494
|
+
return null;
|
|
495
|
+
let inkArea = 0;
|
|
496
|
+
for (let y = y0; y <= y1; y++)
|
|
497
|
+
for (let x = x0; x <= x1; x++)
|
|
498
|
+
if (coverage(x, y) >= 0.5)
|
|
499
|
+
inkArea++;
|
|
500
|
+
if (inkArea === 0 || inkArea > area * 0.6)
|
|
501
|
+
return null;
|
|
502
|
+
const radius = Math.min(Math.min(discW, discH) / 2, Math.sqrt(Math.max(0, discW * discH - area) / (4 - Math.PI)));
|
|
503
|
+
// The field, on the disc's own box: the quad the shield draws IS the plate's box then, so the
|
|
504
|
+
// plate needs no padding and its border lands on the ring the crop just removed.
|
|
505
|
+
const field = buildField(discW, discH, (x, y) => coverage(x0 + x, y0 + y));
|
|
506
|
+
const file = `${safeFileName(writeAs ?? name)}.png`;
|
|
507
|
+
const spriteHost = requireHost();
|
|
508
|
+
spriteHost.writeIcon(outDir, `${GLYPH_DIR}/${file}`, spriteHost.encodePng(field, FIELD_PNG));
|
|
509
|
+
const ratio = entry.pixelRatio && entry.pixelRatio > 0 ? entry.pixelRatio : 1;
|
|
510
|
+
return {
|
|
511
|
+
file: `${GLYPH_DIR}/${file}`,
|
|
512
|
+
width: discW / ratio,
|
|
513
|
+
height: discH / ratio,
|
|
514
|
+
sdf: true,
|
|
515
|
+
pixelRatio: ratio,
|
|
516
|
+
padding: 0, // the field is built on the disc's own box - see buildField above
|
|
517
|
+
plate: { radius: radius / ratio, borderWidth: ringTexels / ratio, strokeWidth: strokeWidth / ratio },
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* A sprite that is not a disc with a glyph on it, as a field of its own SILHOUETTE.
|
|
522
|
+
*
|
|
523
|
+
* MapBox composes a POI icon as `background` + `icon`, and its sheet ships the two already merged;
|
|
524
|
+
* the split above recovers them. What it cannot split - `marker`, MapBox's generic pin, first among
|
|
525
|
+
* them - still has to draw SOMETHING, and the layer states the background separately anyway, so the
|
|
526
|
+
* whole sprite becomes the glyph and the rule's own plate stands in for the disc. Left out, the
|
|
527
|
+
* ~150 POIs that name the generic pin drew their label with no icon at all.
|
|
528
|
+
*
|
|
529
|
+
* It flattens a multi-colour sprite to one shape, which is what the icon-fill of a POI rule means.
|
|
530
|
+
*/
|
|
531
|
+
export function extractIconSilhouette(sprites, name, outDir, writeAs) {
|
|
532
|
+
const [sheetId, iconName] = splitIconName(name);
|
|
533
|
+
const sheet = sprites.get(sheetId);
|
|
534
|
+
const entry = sheet?.index[iconName];
|
|
535
|
+
if (!sheet || !entry || entry.sdf || entry.width < 1 || entry.height < 1)
|
|
536
|
+
return null;
|
|
537
|
+
const alpha = (x, y) => sheet.image.data[((entry.y + y) * sheet.image.width + (entry.x + x)) * 4 + 3] / 255;
|
|
538
|
+
let x0 = entry.width, y0 = entry.height, x1 = -1, y1 = -1;
|
|
539
|
+
for (let y = 0; y < entry.height; y++) {
|
|
540
|
+
for (let x = 0; x < entry.width; x++) {
|
|
541
|
+
if (alpha(x, y) <= 0)
|
|
542
|
+
continue;
|
|
543
|
+
x0 = Math.min(x0, x);
|
|
544
|
+
y0 = Math.min(y0, y);
|
|
545
|
+
x1 = Math.max(x1, x);
|
|
546
|
+
y1 = Math.max(y1, y);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (x1 < x0 || y1 < y0)
|
|
550
|
+
return null;
|
|
551
|
+
const w = x1 - x0 + 1;
|
|
552
|
+
const h = y1 - y0 + 1;
|
|
553
|
+
const field = buildField(w, h, (x, y) => alpha(x0 + x, y0 + y));
|
|
554
|
+
const file = `${safeFileName(writeAs ?? iconName)}.png`;
|
|
555
|
+
const spriteHost = requireHost();
|
|
556
|
+
spriteHost.writeIcon(outDir, `${GLYPH_DIR}/${file}`, spriteHost.encodePng(field, FIELD_PNG));
|
|
557
|
+
const ratio = entry.pixelRatio && entry.pixelRatio > 0 ? entry.pixelRatio : 1;
|
|
558
|
+
return { file: `${GLYPH_DIR}/${file}`, width: w / ratio, height: h / ratio, sdf: true, pixelRatio: ratio, padding: 0 };
|
|
559
|
+
}
|
|
560
|
+
/** Every icon of every sheet that IS a composite, split into a glyph field and a plate. */
|
|
561
|
+
export function extractAllIconPlates(sprites, outDir) {
|
|
562
|
+
const entries = [];
|
|
563
|
+
const skipped = [];
|
|
564
|
+
const seen = new Set();
|
|
565
|
+
let sample = null;
|
|
566
|
+
for (const [sheetId, sheet] of sprites) {
|
|
567
|
+
for (const name of Object.keys(sheet.index)) {
|
|
568
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name) || seen.has(name))
|
|
569
|
+
continue;
|
|
570
|
+
const qualified = sheetId === 'default' ? name : `${sheetId}:${name}`;
|
|
571
|
+
// A sprite that is not a composite still has to draw: its own silhouette, on the rule's
|
|
572
|
+
// plate. See extractIconSilhouette.
|
|
573
|
+
const icon = extractIconPlate(sprites, qualified, outDir, name)
|
|
574
|
+
?? extractIconSilhouette(sprites, qualified, outDir, name);
|
|
575
|
+
if (!icon) {
|
|
576
|
+
skipped.push(name);
|
|
577
|
+
continue;
|
|
578
|
+
}
|
|
579
|
+
seen.add(name);
|
|
580
|
+
entries.push({ name, icon });
|
|
581
|
+
if (!sample)
|
|
582
|
+
sample = icon;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
return { entries, skipped, sample };
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Room for the halo to fade in, outside the shape.
|
|
589
|
+
*
|
|
590
|
+
* MapBox's field only describes what its tiny-sdf radius reached - 2 texels outside the ink at
|
|
591
|
+
* cutoff 0.25 - and a MapTiler sprite cell is cut tight around its artwork, so the field is still
|
|
592
|
+
* well above "fully outside" where the bitmap ends. The renderer draws a halo wherever the field is
|
|
593
|
+
* within the halo width of the edge, so it drew one along the QUAD BORDER: a white rectangle round
|
|
594
|
+
* every POI icon, and a white outline round every tree.
|
|
595
|
+
*
|
|
596
|
+
* The padding continues the ramp outward at the field's own rate (one texel = MASSIF_SDF_UNIT)
|
|
597
|
+
* from the nearest border pixel, rather than filling a constant - a constant just moves the same
|
|
598
|
+
* step outward and the halo follows it there.
|
|
599
|
+
*
|
|
600
|
+
* It grows the quad, and with it the label's collision box, so it is kept to what a typical
|
|
601
|
+
* `icon-halo-width` of 2 needs rather than the widest a style could ask for.
|
|
602
|
+
*/
|
|
603
|
+
const SDF_PADDING = 6;
|
|
604
|
+
function padField(source, pixelRatio) {
|
|
605
|
+
const p = Math.round(SDF_PADDING * pixelRatio);
|
|
606
|
+
const width = source.width + 2 * p;
|
|
607
|
+
const height = source.height + 2 * p;
|
|
608
|
+
const out = createImage(width, height);
|
|
609
|
+
// The distance is re-derived from the INK by the same exact Euclidean transform MapBox's own
|
|
610
|
+
// tiny-sdf uses (Felzenszwalb & Huttenlocher). Two reasons it cannot just be read back:
|
|
611
|
+
// MapBox's field spans only -6..+2 texels around the shape, so everything further out carries
|
|
612
|
+
// one saturated value and a wider halo lit the whole sprite square; and a cheap chamfer sweep
|
|
613
|
+
// is ~7% out along the diagonals, which showed as a scalloped halo edge and patchy holes.
|
|
614
|
+
//
|
|
615
|
+
// SIGNED - outward from the ink and inward from its complement. One-sided leaves a step where
|
|
616
|
+
// the kept MapBox value meets the derived one, and the halo threshold crosses it twice.
|
|
617
|
+
const INF = 1e20;
|
|
618
|
+
const at = (x, y) => {
|
|
619
|
+
const sx = x - p;
|
|
620
|
+
const sy = y - p;
|
|
621
|
+
if (sx < 0 || sy < 0 || sx >= source.width || sy >= source.height)
|
|
622
|
+
return null;
|
|
623
|
+
return source.data[(sy * source.width + sx) * 4];
|
|
624
|
+
};
|
|
625
|
+
const outward = new Float64Array(width * height);
|
|
626
|
+
const inward = new Float64Array(width * height);
|
|
627
|
+
for (let y = 0; y < height; y++) {
|
|
628
|
+
for (let x = 0; x < width; x++) {
|
|
629
|
+
const i = y * width + x;
|
|
630
|
+
const value = at(x, y);
|
|
631
|
+
const ink = value !== null && value >= MASSIF_SDF_EDGE;
|
|
632
|
+
outward[i] = ink ? 0 : INF;
|
|
633
|
+
inward[i] = ink ? INF : 0;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
edt(outward, width, height);
|
|
637
|
+
edt(inward, width, height);
|
|
638
|
+
for (let y = 0; y < height; y++) {
|
|
639
|
+
for (let x = 0; x < width; x++) {
|
|
640
|
+
const i = y * width + x;
|
|
641
|
+
// Signed distance in texels, positive inside. MapBox's own value is kept within a texel
|
|
642
|
+
// and a half of the edge - it carries sub-texel coverage a thresholded mask cannot -
|
|
643
|
+
// and the two agree there, so the field stays continuous across the handover.
|
|
644
|
+
//
|
|
645
|
+
// NOT clamped to tiny-sdf's -6..+2: MapBox can afford that span because it draws a
|
|
646
|
+
// sprite at about one texel per screen pixel, where the SDK draws the same sprite at
|
|
647
|
+
// ~1.4 and six texels would fund barely three pixels of halo.
|
|
648
|
+
const derived = Math.sqrt(inward[i]) - Math.sqrt(outward[i]);
|
|
649
|
+
const own = at(x, y);
|
|
650
|
+
const ownTexels = own === null ? null : (own - MASSIF_SDF_EDGE) / MASSIF_SDF_UNIT;
|
|
651
|
+
const signed = ownTexels !== null && Math.abs(derived) <= 1.5 ? ownTexels : derived;
|
|
652
|
+
const texels = Math.max(-FIELD_LIMIT, Math.min(FIELD_LIMIT, signed));
|
|
653
|
+
const value = Math.max(0, Math.min(255, Math.round(MASSIF_SDF_EDGE + texels * MASSIF_SDF_UNIT)));
|
|
654
|
+
const dst = i * 4;
|
|
655
|
+
out.data[dst] = out.data[dst + 1] = out.data[dst + 2] = value;
|
|
656
|
+
out.data[dst + 3] = 255;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
return out;
|
|
660
|
+
}
|
|
661
|
+
/** Bilinear resample. Sprites are tens of pixels, so the simplest correct thing is fast enough. */
|
|
662
|
+
function resample(source, scale) {
|
|
663
|
+
const width = Math.max(1, Math.round(source.width * scale));
|
|
664
|
+
const height = Math.max(1, Math.round(source.height * scale));
|
|
665
|
+
const out = createImage(width, height);
|
|
666
|
+
for (let y = 0; y < height; y++) {
|
|
667
|
+
const sy = Math.min(source.height - 1, (y + 0.5) / scale - 0.5);
|
|
668
|
+
const y0 = Math.max(0, Math.floor(sy));
|
|
669
|
+
const y1 = Math.min(source.height - 1, y0 + 1);
|
|
670
|
+
const fy = sy - y0;
|
|
671
|
+
for (let x = 0; x < width; x++) {
|
|
672
|
+
const sx = Math.min(source.width - 1, (x + 0.5) / scale - 0.5);
|
|
673
|
+
const x0 = Math.max(0, Math.floor(sx));
|
|
674
|
+
const x1 = Math.min(source.width - 1, x0 + 1);
|
|
675
|
+
const fx = sx - x0;
|
|
676
|
+
for (let c = 0; c < 4; c++) {
|
|
677
|
+
const at = (px, py) => source.data[(py * source.width + px) * 4 + c];
|
|
678
|
+
const top = at(x0, y0) * (1 - fx) + at(x1, y0) * fx;
|
|
679
|
+
const bottom = at(x0, y1) * (1 - fx) + at(x1, y1) * fx;
|
|
680
|
+
out.data[(y * width + x) * 4 + c] = Math.round(top * (1 - fy) + bottom * fy);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
return out;
|
|
685
|
+
}
|
|
686
|
+
function smoothstep(edge0, edge1, x) {
|
|
687
|
+
const t = Math.min(1, Math.max(0, (x - edge0) / (edge1 - edge0)));
|
|
688
|
+
return t * t * (3 - 2 * t);
|
|
689
|
+
}
|
|
690
|
+
function safeFileName(name) {
|
|
691
|
+
return name.replace(/[^A-Za-z0-9_.-]/g, '_');
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Every icon of every sheet, written out under its own name.
|
|
695
|
+
*
|
|
696
|
+
* MapTiler picks a POI's icon from the FEATURE - `coalesce(image(subclass), image(class),
|
|
697
|
+
* image('dot'))` - so there is no one name to slice out. The SDK resolves `shield-file` per feature
|
|
698
|
+
* and mapnik interpolates `[field]` inside a string, so the whole set is written and the style
|
|
699
|
+
* names the file with the field in it. Only names that survive a file name unchanged are written,
|
|
700
|
+
* because the interpolation has to land on the file exactly.
|
|
701
|
+
*/
|
|
702
|
+
export function extractAllIcons(sprites, outDir, flattenSdf = false) {
|
|
703
|
+
const names = [];
|
|
704
|
+
const skipped = [];
|
|
705
|
+
let sample = null;
|
|
706
|
+
for (const [sheetId, sheet] of sprites) {
|
|
707
|
+
for (const name of Object.keys(sheet.index)) {
|
|
708
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name)) {
|
|
709
|
+
skipped.push(name);
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
712
|
+
// EVERY sheet, written under its bare name: a style names a sprite per feature and
|
|
713
|
+
// the file has to be the one a `[field]` interpolation lands on, which a qualified
|
|
714
|
+
// 'transportation_road_3.png' never is. The default sheet wins a collision, since a
|
|
715
|
+
// bare icon-image refers to it.
|
|
716
|
+
if (names.includes(name))
|
|
717
|
+
continue;
|
|
718
|
+
const icon = extractIcon(sprites, sheetId === 'default' ? name : `${sheetId}:${name}`, outDir, flattenSdf, undefined, 1, name);
|
|
719
|
+
if (!icon)
|
|
720
|
+
continue;
|
|
721
|
+
names.push(name);
|
|
722
|
+
if (!sample || name === 'dot')
|
|
723
|
+
sample = icon;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return { names, skipped, sample };
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* Exact Euclidean distance transform, Felzenszwalb & Huttenlocher - the one MapBox's tiny-sdf uses.
|
|
730
|
+
* `grid` holds SQUARED distances in place: 0 on the shape, a large number off it.
|
|
731
|
+
*
|
|
732
|
+
* Each pass is the lower envelope of the parabolas rooted at every cell of one row or column, which
|
|
733
|
+
* is what makes it exact and linear where a chamfer sweep is neither.
|
|
734
|
+
*/
|
|
735
|
+
function edt(grid, width, height) {
|
|
736
|
+
const size = Math.max(width, height);
|
|
737
|
+
const f = new Float64Array(size);
|
|
738
|
+
const d = new Float64Array(size);
|
|
739
|
+
const v = new Int32Array(size);
|
|
740
|
+
const z = new Float64Array(size + 1);
|
|
741
|
+
const FAR = 1e20;
|
|
742
|
+
const pass = (n, read, write) => {
|
|
743
|
+
for (let i = 0; i < n; i++)
|
|
744
|
+
f[i] = read(i);
|
|
745
|
+
let k = 0;
|
|
746
|
+
v[0] = 0;
|
|
747
|
+
z[0] = -FAR;
|
|
748
|
+
z[1] = FAR;
|
|
749
|
+
for (let q = 1; q < n; q++) {
|
|
750
|
+
let s = 0;
|
|
751
|
+
while (k >= 0) {
|
|
752
|
+
s = ((f[q] + q * q) - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
|
|
753
|
+
if (s > z[k])
|
|
754
|
+
break;
|
|
755
|
+
k--;
|
|
756
|
+
}
|
|
757
|
+
k++;
|
|
758
|
+
v[k] = q;
|
|
759
|
+
z[k] = s;
|
|
760
|
+
z[k + 1] = FAR;
|
|
761
|
+
}
|
|
762
|
+
k = 0;
|
|
763
|
+
for (let q = 0; q < n; q++) {
|
|
764
|
+
while (z[k + 1] < q)
|
|
765
|
+
k++;
|
|
766
|
+
const dx = q - v[k];
|
|
767
|
+
d[q] = dx * dx + f[v[k]];
|
|
768
|
+
}
|
|
769
|
+
for (let i = 0; i < n; i++)
|
|
770
|
+
write(i, d[i]);
|
|
771
|
+
};
|
|
772
|
+
for (let x = 0; x < width; x++) {
|
|
773
|
+
pass(height, (y) => grid[y * width + x], (y, value) => { grid[y * width + x] = value; });
|
|
774
|
+
}
|
|
775
|
+
for (let y = 0; y < height; y++) {
|
|
776
|
+
const row = y * width;
|
|
777
|
+
pass(width, (x) => grid[row + x], (x, value) => { grid[row + x] = value; });
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/** How far two texels of the same flat may drift and still count as one colour. */
|
|
781
|
+
const FLAT_TOLERANCE = 12;
|
|
782
|
+
function hex(rgb) {
|
|
783
|
+
return '#' + rgb.slice(0, 3).map((v) => Math.round(v).toString(16).padStart(2, '0')).join('');
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* A sprite that is nothing but a rounded rectangle: one flat interior, an optional border of one
|
|
787
|
+
* other flat, and no ink between them.
|
|
788
|
+
*
|
|
789
|
+
* That is what a road shield outside the US is, and the SDK draws it without an image at all -
|
|
790
|
+
* `shield-background-fill` and `-border-fill` on the label's own plate. So the colours are read
|
|
791
|
+
* back off the artwork rather than named by a convention the style would have to carry, and a
|
|
792
|
+
* sprite that IS artwork - a US interstate, a roundel, a POI pin - fails one of the flat tests and
|
|
793
|
+
* keeps its bitmap.
|
|
794
|
+
*/
|
|
795
|
+
export function describeFlatPlate(sprites, name) {
|
|
796
|
+
const [sheetId, iconName] = splitIconName(name);
|
|
797
|
+
const sheet = sprites.get(sheetId);
|
|
798
|
+
const entry = sheet?.index[iconName];
|
|
799
|
+
if (!sheet || !entry || entry.sdf || entry.width < 8 || entry.height < 8)
|
|
800
|
+
return null;
|
|
801
|
+
const at = (x, y) => {
|
|
802
|
+
const i = ((entry.y + y) * sheet.image.width + (entry.x + x)) * 4;
|
|
803
|
+
return [sheet.image.data[i], sheet.image.data[i + 1], sheet.image.data[i + 2], sheet.image.data[i + 3]];
|
|
804
|
+
};
|
|
805
|
+
const opaque = (x, y) => at(x, y)[3] >= FLAT_ALPHA;
|
|
806
|
+
const same = (a, b) => Math.abs(a[0] - b[0]) <= FLAT_TOLERANCE && Math.abs(a[1] - b[1]) <= FLAT_TOLERANCE &&
|
|
807
|
+
Math.abs(a[2] - b[2]) <= FLAT_TOLERANCE;
|
|
808
|
+
let x0 = entry.width, y0 = entry.height, x1 = -1, y1 = -1, area = 0;
|
|
809
|
+
for (let y = 0; y < entry.height; y++) {
|
|
810
|
+
for (let x = 0; x < entry.width; x++) {
|
|
811
|
+
if (!opaque(x, y))
|
|
812
|
+
continue;
|
|
813
|
+
area++;
|
|
814
|
+
x0 = Math.min(x0, x);
|
|
815
|
+
y0 = Math.min(y0, y);
|
|
816
|
+
x1 = Math.max(x1, x);
|
|
817
|
+
y1 = Math.max(y1, y);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
const width = x1 - x0 + 1, height = y1 - y0 + 1;
|
|
821
|
+
if (x1 < x0 || y1 < y0 || width < 8 || height < 6)
|
|
822
|
+
return null;
|
|
823
|
+
const midY = (y0 + y1) >> 1;
|
|
824
|
+
const fill = at((x0 + x1) >> 1, midY);
|
|
825
|
+
if (fill[3] < FLAT_ALPHA)
|
|
826
|
+
return null;
|
|
827
|
+
// The border is the run of one OTHER flat the middle row crosses before the fill. The two do
|
|
828
|
+
// not meet cleanly - a stroke is antialiased against what it covers - so the blend between them
|
|
829
|
+
// is walked past rather than measured, and only the flats on either side are read.
|
|
830
|
+
const edge = at(x0, midY);
|
|
831
|
+
let borderWidth = 0;
|
|
832
|
+
while (borderWidth < width / 3 && same(at(x0 + borderWidth, midY), edge))
|
|
833
|
+
borderWidth++;
|
|
834
|
+
const border = same(edge, fill) ? fill : edge;
|
|
835
|
+
if (same(edge, fill))
|
|
836
|
+
borderWidth = 0;
|
|
837
|
+
let inner = borderWidth;
|
|
838
|
+
while (inner < width / 2 && !same(at(x0 + inner, midY), fill))
|
|
839
|
+
inner++;
|
|
840
|
+
if (inner >= width / 2)
|
|
841
|
+
return null;
|
|
842
|
+
// The OUTER edge of a stroke is antialiased against nothing, so its last texels fall under
|
|
843
|
+
// FLAT_ALPHA and the box starts INSIDE them - a 1.3 px stroke measured 1.0 at ratio 2, its
|
|
844
|
+
// outermost 0.75 texel dropped. Alpha there IS coverage, so it is added back rather than
|
|
845
|
+
// rounded away. The inner edge needs no such thing: it meets the fill at full alpha.
|
|
846
|
+
let outer = 0;
|
|
847
|
+
for (let x = x0 - 1; x >= 0 && borderWidth > 0; x--) {
|
|
848
|
+
const texel = at(x, midY);
|
|
849
|
+
if (texel[3] === 0 || texel[3] >= FLAT_ALPHA || !same(texel, edge))
|
|
850
|
+
break;
|
|
851
|
+
outer += texel[3] / 255;
|
|
852
|
+
}
|
|
853
|
+
// A rounded rectangle fills its box; a shield outline does not.
|
|
854
|
+
if (area < 0.85 * width * height)
|
|
855
|
+
return null;
|
|
856
|
+
// A rounded rect of w by h with corner radius r loses (4 - pi) r^2 to its corners.
|
|
857
|
+
const radiusTexels = Math.min(Math.min(width, height) / 2, Math.sqrt(Math.max(0, width * height - area) / (4 - Math.PI)));
|
|
858
|
+
// Uniform inside: one texel of another colour is a glyph or a second field, and this is
|
|
859
|
+
// artwork - the interstate's red crown is caught here. The four CORNERS are where a rounded
|
|
860
|
+
// rect leaves its box, and where the border curves through the inset, so they are skipped.
|
|
861
|
+
const inset = inner + 1;
|
|
862
|
+
const corner = Math.ceil(radiusTexels) + inset;
|
|
863
|
+
for (let y = y0 + inset; y <= y1 - inset; y++) {
|
|
864
|
+
for (let x = x0 + inset; x <= x1 - inset; x++) {
|
|
865
|
+
const inCorner = (x - x0 < corner || x1 - x < corner) && (y - y0 < corner || y1 - y < corner);
|
|
866
|
+
if (inCorner)
|
|
867
|
+
continue;
|
|
868
|
+
if (opaque(x, y) && !same(at(x, y), fill))
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (borderWidth > 0) {
|
|
873
|
+
const midX = (x0 + x1) >> 1;
|
|
874
|
+
for (const [x, y] of [[x1, midY], [midX, y0], [midX, y1]]) {
|
|
875
|
+
if (!same(at(x, y), border))
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
const ratio = entry.pixelRatio && entry.pixelRatio > 0 ? entry.pixelRatio : 1;
|
|
880
|
+
return { fill: hex(fill), border: hex(border), borderWidth: (borderWidth + outer) / ratio, radius: radiusTexels / ratio };
|
|
881
|
+
}
|
|
882
|
+
//# sourceMappingURL=sprite.js.map
|