Skip to content

Commit c690ca0

Browse files
authored
Merge pull request #179 from UW-Macrostrat/static-map-styling
Static map styling
2 parents 455edb9 + 32187e4 commit c690ca0

File tree

19 files changed

+677
-283
lines changed

19 files changed

+677
-283
lines changed

packages/map-styles/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format
44
is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this
55
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
66

7+
## [1.2.3] - 2025-11-02
8+
9+
- Added `setupStyleImageManager` function and associated utilities to manage
10+
style images
11+
- Began adding more streamlined code for map symbol management
12+
- Added utilities for loading SVG-based map symbols
13+
714
## [1.2.2] - 2025-10-05
815

916
- Add `pointSymbolIndex` to allow custom loading of symbols by external code

packages/map-styles/package.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@macrostrat/map-styles",
3-
"version": "1.2.2",
3+
"version": "1.2.3",
44
"description": "Utilities for working with Mapbox map styles",
55
"main": "dist/index.js",
66
"types": "dist/index.d.ts",
@@ -28,6 +28,7 @@
2828
"dependencies": {
2929
"@macrostrat/color-utils": "workspace:*",
3030
"axios": "^1.7.9",
31-
"mapbox-gl": "^2.15.0||^3.0.0"
31+
"mapbox-gl": "^2.15.0||^3.0.0",
32+
"textures": "^1.2.3"
3233
}
3334
}

packages/map-styles/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ import "./images.d.ts";
22

33
export * from "./layer-helpers";
44
export * from "./styles";
5+
export * from "./style-image-manager";

packages/map-styles/src/layer-helpers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import mapboxgl from "mapbox-gl";
1010
import "mapbox-gl/dist/mapbox-gl.css";
1111
import { lineSymbols } from "./symbol-layers";
1212
import { loadImage } from "./utils";
13+
export * from "./svg-patterns";
1314

1415
export interface LayerDescription {
1516
id: string;

packages/map-styles/src/layer-helpers/pattern-fill.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
interface PatternFillSpec {
1+
export interface PatternFillSpec {
22
color: string;
33
patternURL?: string;
44
patternColor?: string;
55
}
66

7-
function loadImage(url): Promise<HTMLImageElement> {
7+
export function loadImage(url): Promise<HTMLImageElement> {
88
return new Promise((resolve, reject) => {
99
const img = new Image();
1010
img.crossOrigin = "anonymous";
@@ -14,7 +14,7 @@ function loadImage(url): Promise<HTMLImageElement> {
1414
});
1515
}
1616

17-
function recolorPatternImage(
17+
export function recolorPatternImage(
1818
img: HTMLImageElement,
1919
backgroundColor: string,
2020
color: string,
@@ -51,7 +51,7 @@ function recolorPatternImage(
5151
return ctx.getImageData(0, 0, img.width, img.height);
5252
}
5353

54-
function createSolidColorImage(imgColor) {
54+
export function createSolidColorImage(imgColor) {
5555
var canvas = document.createElement("canvas");
5656
var ctx = canvas.getContext("2d");
5757
canvas.width = 40;
@@ -62,7 +62,7 @@ function createSolidColorImage(imgColor) {
6262
return ctx.getImageData(0, 0, 40, 40);
6363
}
6464

65-
async function createUnitFill(spec: PatternFillSpec): Promise<ImageData> {
65+
export async function createUnitFill(spec: PatternFillSpec): Promise<ImageData> {
6666
/** Create a fill image for a map unit. */
6767
if (spec.patternURL != null) {
6868
const img = await loadImage(spec.patternURL);
@@ -72,4 +72,3 @@ async function createUnitFill(spec: PatternFillSpec): Promise<ImageData> {
7272
}
7373
}
7474

75-
export { recolorPatternImage, createUnitFill };
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
export async function renderTexturesPattern(
2+
spec: any,
3+
options?: RasterizeSVGOptions,
4+
) {
5+
/** Render pattern defined by a riccardoscalco/textures spec to ImageData */
6+
// Create SVG and render pattern
7+
8+
const d3Sel = await import("d3-selection");
9+
10+
const sel = d3Sel.select(document.body);
11+
12+
const svg = sel.append("svg");
13+
14+
svg.call(spec);
15+
16+
const pattern = svg.select("pattern");
17+
const width = pattern.attr("width");
18+
const height = pattern.attr("height");
19+
20+
svg.attr("width", width);
21+
svg.attr("height", height);
22+
23+
// Apply the pattern to a rectangle
24+
svg
25+
.append("rect")
26+
.attr("width", width)
27+
.attr("height", height)
28+
.attr("fill", spec.url());
29+
30+
const el = svg.node();
31+
32+
const data = await rasterizeSVG(el, options);
33+
34+
svg.remove();
35+
36+
return data;
37+
}
38+
39+
// From https://observablehq.com/@mbostock/saving-svg
40+
const xmlns = "http://www.w3.org/2000/xmlns/";
41+
const xlinkns = "http://www.w3.org/1999/xlink";
42+
const svgns = "http://www.w3.org/2000/svg";
43+
44+
function serializeSVG(svg: SVGElement) {
45+
svg = svg.cloneNode(true);
46+
const fragment = window.location.href + "#";
47+
const walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT);
48+
while (walker.nextNode()) {
49+
for (const attr of walker.currentNode.attributes) {
50+
if (attr.value.includes(fragment)) {
51+
attr.value = attr.value.replace(fragment, "#");
52+
}
53+
}
54+
}
55+
svg.setAttributeNS(xmlns, "xmlns", svgns);
56+
svg.setAttributeNS(xmlns, "xmlns:xlink", xlinkns);
57+
const serializer = new window.XMLSerializer();
58+
const string = serializer.serializeToString(svg);
59+
return new Blob([string], { type: "image/svg+xml" });
60+
}
61+
62+
export interface RasterizeSVGOptions {
63+
pixelRatio?: number;
64+
}
65+
66+
async function rasterizeSVG(
67+
svg: SVGElement,
68+
options?: RasterizeSVGOptions,
69+
): Promise<ImageData> {
70+
const pixelScale = options?.pixelRatio ?? 4;
71+
return new Promise((resolve, reject) => {
72+
const canvas = document.createElement("canvas");
73+
const image = new Image();
74+
image.onerror = reject;
75+
image.onload = () => {
76+
const rect = svg.getBoundingClientRect();
77+
const ctx = canvas.getContext("2d");
78+
ctx.drawImage(
79+
image,
80+
0,
81+
0,
82+
rect.width * pixelScale,
83+
rect.height * pixelScale,
84+
);
85+
const data = ctx.getImageData(
86+
0,
87+
0,
88+
rect.width * pixelScale,
89+
rect.height * pixelScale,
90+
);
91+
resolve(data);
92+
};
93+
image.src = URL.createObjectURL(serializeSVG(svg));
94+
});
95+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import {
2+
createSolidColorImage,
3+
loadImage,
4+
} from "../layer-helpers/pattern-fill";
5+
import { createPatternImage } from "@macrostrat/ui-components";
6+
7+
export interface StyleImageManagerOptions {
8+
baseURL?: string;
9+
pixelRatio?: number;
10+
resolvers?: Record<string, PatternResolverFunction>;
11+
throwOnMissing?: boolean;
12+
}
13+
14+
type PatternResolverFunction = (
15+
key: string,
16+
args: string[],
17+
options: StyleImageManagerOptions,
18+
) => Promise<PatternResult | ImageResult>;
19+
20+
type ImageResult =
21+
| HTMLImageElement
22+
| ImageData
23+
| { url: string; options?: AddImageOptions }
24+
| null;
25+
26+
interface PatternResult {
27+
image: ImageResult;
28+
options?: AddImageOptions;
29+
}
30+
31+
interface AddImageOptions {
32+
sdf?: boolean;
33+
pixelRatio?: number;
34+
}
35+
36+
export function setupStyleImageManager(
37+
map: any,
38+
options: StyleImageManagerOptions = {},
39+
): () => void {
40+
const { throwOnMissing = false } = options;
41+
const styleImageMissing = (e) => {
42+
loadStyleImage(map, e.id, options)
43+
.catch((err) => {
44+
if (throwOnMissing) {
45+
throw err;
46+
}
47+
console.error(`Failed to load pattern image for ${e.id}:`, err);
48+
})
49+
.then(() => {});
50+
};
51+
52+
// Register the event listener for missing images
53+
map.on("styleimagemissing", styleImageMissing);
54+
return () => {
55+
// Clean up the event listener when the component unmounts
56+
map.off("styleimagemissing", styleImageMissing);
57+
};
58+
}
59+
60+
async function loadStyleImage(
61+
map: mapboxgl.Map,
62+
id: string,
63+
options: StyleImageManagerOptions = {},
64+
) {
65+
const { pixelRatio = 3 } = options;
66+
const [prefix, name, ...rest] = id.split(":");
67+
68+
const { resolvers = defaultResolvers } = options;
69+
70+
// Match the prefix to a resolver function
71+
if (prefix in resolvers) {
72+
const resolver = resolvers[prefix];
73+
const result = await resolver(prefix, [name, ...rest], options);
74+
75+
let image: ImageResult = null;
76+
let addOptions: AddImageOptions = { pixelRatio };
77+
78+
if (result != null) {
79+
if ("options" in result) {
80+
addOptions = { ...addOptions, ...(result.options ?? {}) };
81+
}
82+
if ("image" in result) {
83+
image = result.image;
84+
addOptions = { ...addOptions, ...(result.options ?? {}) };
85+
} else {
86+
image = result;
87+
}
88+
}
89+
if (image == null) {
90+
throw new Error(`No image returned by resolver for pattern: ${id}`);
91+
}
92+
if (typeof image === "object" && "url" in image) {
93+
await addImageURLToMap(map, id, image.url, addOptions);
94+
} else {
95+
addImageToMap(map, id, image, addOptions);
96+
}
97+
return;
98+
}
99+
}
100+
101+
enum SymbolImageFormat {
102+
PNG = "png",
103+
SVG = "svg",
104+
}
105+
106+
function resolveLineSymbolImage(
107+
id: string,
108+
args: string[],
109+
options: StyleImageManagerOptions = {},
110+
) {
111+
return resolveSymbolImage(
112+
"geologic-symbols/lines/dev",
113+
args,
114+
SymbolImageFormat.PNG,
115+
options,
116+
);
117+
}
118+
119+
function resolvePointSymbolImage(
120+
id: string,
121+
args: string[],
122+
options: StyleImageManagerOptions = {},
123+
) {
124+
return resolveSymbolImage(
125+
"geologic-symbols/points/strabospot",
126+
args,
127+
SymbolImageFormat.PNG,
128+
options,
129+
);
130+
}
131+
132+
function resolveSymbolImage(
133+
set: string,
134+
args: string[],
135+
format: SymbolImageFormat = SymbolImageFormat.PNG,
136+
options: StyleImageManagerOptions = {},
137+
) {
138+
const { baseURL = "https://dev.macrostrat.org/assets/web" } = options;
139+
const [name, ...rest] = args;
140+
const lineSymbolsURL = `${baseURL}/${set}/${format}`;
141+
return { url: lineSymbolsURL + `/${name}.${format}`, options: { sdf: true } };
142+
}
143+
144+
export function addImageToMap(
145+
map: mapboxgl.Map,
146+
id: string,
147+
image: HTMLImageElement | ImageData | null,
148+
options: AddImageOptions,
149+
) {
150+
if (map.hasImage(id) || image == null) return;
151+
map.addImage(id, image, options);
152+
}
153+
154+
export async function addImageURLToMap(
155+
map: mapboxgl.Map,
156+
id: string,
157+
url: string,
158+
options: AddImageOptions,
159+
) {
160+
if (map.hasImage(id)) return;
161+
const image = await loadImage(url);
162+
addImageToMap(map, id, image, options);
163+
}
164+
165+
async function resolveFGDCImage(
166+
key: string,
167+
args: string[],
168+
options: StyleImageManagerOptions,
169+
): Promise<PatternResult | null> {
170+
const { baseURL = "https://dev.macrostrat.org/assets/web" } = options;
171+
const [name, color, backgroundColor = "transparent"] = args;
172+
173+
const num = parseInt(name);
174+
let patternName = name;
175+
if (num == NaN) {
176+
throw new Error(`Invalid FGDC pattern name: ${name}`);
177+
}
178+
if (num <= 599) {
179+
// FGDC 1-599 are fill patterns
180+
// Check if pattern ID has a suffix, or if not add one
181+
patternName = `${num}-K`;
182+
}
183+
184+
const image = (await createPatternImage({
185+
patternURL: `${baseURL}/geologic-patterns/png/${patternName}.png`,
186+
color: backgroundColor,
187+
patternColor: color,
188+
})) as ImageData;
189+
190+
return image;
191+
}
192+
193+
async function resolveSolidColorImage(
194+
key: string,
195+
args: string[],
196+
options: StyleImageManagerOptions,
197+
): Promise<ImageData> {
198+
return createSolidColorImage(args[0]);
199+
}
200+
201+
export const defaultResolvers: Record<string, PatternResolverFunction> = {
202+
fgdc: resolveFGDCImage,
203+
color: resolveSolidColorImage,
204+
"line-symbol": resolveLineSymbolImage,
205+
point: resolvePointSymbolImage,
206+
};

0 commit comments

Comments
 (0)