Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/map-styles/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file. The format
is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this
project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.2.3] - 2025-11-02

- Added `setupStyleImageManager` function and associated utilities to manage
style images
- Began adding more streamlined code for map symbol management
- Added utilities for loading SVG-based map symbols

## [1.2.2] - 2025-10-05

- Add `pointSymbolIndex` to allow custom loading of symbols by external code
Expand Down
5 changes: 3 additions & 2 deletions packages/map-styles/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@macrostrat/map-styles",
"version": "1.2.2",
"version": "1.2.3",
"description": "Utilities for working with Mapbox map styles",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down Expand Up @@ -28,6 +28,7 @@
"dependencies": {
"@macrostrat/color-utils": "workspace:*",
"axios": "^1.7.9",
"mapbox-gl": "^2.15.0||^3.0.0"
"mapbox-gl": "^2.15.0||^3.0.0",
"textures": "^1.2.3"
}
}
1 change: 1 addition & 0 deletions packages/map-styles/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ import "./images.d.ts";

export * from "./layer-helpers";
export * from "./styles";
export * from "./style-image-manager";
1 change: 1 addition & 0 deletions packages/map-styles/src/layer-helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import { lineSymbols } from "./symbol-layers";
import { loadImage } from "./utils";
export * from "./svg-patterns";

export interface LayerDescription {
id: string;
Expand Down
11 changes: 5 additions & 6 deletions packages/map-styles/src/layer-helpers/pattern-fill.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
interface PatternFillSpec {
export interface PatternFillSpec {
color: string;
patternURL?: string;
patternColor?: string;
}

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

function recolorPatternImage(
export function recolorPatternImage(
img: HTMLImageElement,
backgroundColor: string,
color: string,
Expand Down Expand Up @@ -51,7 +51,7 @@ function recolorPatternImage(
return ctx.getImageData(0, 0, img.width, img.height);
}

function createSolidColorImage(imgColor) {
export function createSolidColorImage(imgColor) {
var canvas = document.createElement("canvas");
var ctx = canvas.getContext("2d");
canvas.width = 40;
Expand All @@ -62,7 +62,7 @@ function createSolidColorImage(imgColor) {
return ctx.getImageData(0, 0, 40, 40);
}

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

export { recolorPatternImage, createUnitFill };
95 changes: 95 additions & 0 deletions packages/map-styles/src/layer-helpers/svg-patterns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
export async function renderTexturesPattern(
spec: any,
options?: RasterizeSVGOptions,
) {
/** Render pattern defined by a riccardoscalco/textures spec to ImageData */
// Create SVG and render pattern

const d3Sel = await import("d3-selection");

const sel = d3Sel.select(document.body);

const svg = sel.append("svg");

svg.call(spec);

const pattern = svg.select("pattern");
const width = pattern.attr("width");
const height = pattern.attr("height");

svg.attr("width", width);
svg.attr("height", height);

// Apply the pattern to a rectangle
svg
.append("rect")
.attr("width", width)
.attr("height", height)
.attr("fill", spec.url());

const el = svg.node();

const data = await rasterizeSVG(el, options);

svg.remove();

return data;
}

// From https://observablehq.com/@mbostock/saving-svg
const xmlns = "http://www.w3.org/2000/xmlns/";
const xlinkns = "http://www.w3.org/1999/xlink";
const svgns = "http://www.w3.org/2000/svg";

function serializeSVG(svg: SVGElement) {
svg = svg.cloneNode(true);
const fragment = window.location.href + "#";
const walker = document.createTreeWalker(svg, NodeFilter.SHOW_ELEMENT);
while (walker.nextNode()) {
for (const attr of walker.currentNode.attributes) {
if (attr.value.includes(fragment)) {
attr.value = attr.value.replace(fragment, "#");
}
}
}
svg.setAttributeNS(xmlns, "xmlns", svgns);
svg.setAttributeNS(xmlns, "xmlns:xlink", xlinkns);
const serializer = new window.XMLSerializer();
const string = serializer.serializeToString(svg);
return new Blob([string], { type: "image/svg+xml" });
}

export interface RasterizeSVGOptions {
pixelRatio?: number;
}

async function rasterizeSVG(
svg: SVGElement,
options?: RasterizeSVGOptions,
): Promise<ImageData> {
const pixelScale = options?.pixelRatio ?? 4;
return new Promise((resolve, reject) => {
const canvas = document.createElement("canvas");
const image = new Image();
image.onerror = reject;
image.onload = () => {
const rect = svg.getBoundingClientRect();
const ctx = canvas.getContext("2d");
ctx.drawImage(
image,
0,
0,
rect.width * pixelScale,
rect.height * pixelScale,
);
const data = ctx.getImageData(
0,
0,
rect.width * pixelScale,
rect.height * pixelScale,
);
resolve(data);
};
image.src = URL.createObjectURL(serializeSVG(svg));
});
}
206 changes: 206 additions & 0 deletions packages/map-styles/src/style-image-manager/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import {
createSolidColorImage,
loadImage,
} from "../layer-helpers/pattern-fill";
import { createPatternImage } from "@macrostrat/ui-components";

export interface StyleImageManagerOptions {
baseURL?: string;
pixelRatio?: number;
resolvers?: Record<string, PatternResolverFunction>;
throwOnMissing?: boolean;
}

type PatternResolverFunction = (
key: string,
args: string[],
options: StyleImageManagerOptions,
) => Promise<PatternResult | ImageResult>;

type ImageResult =
| HTMLImageElement
| ImageData
| { url: string; options?: AddImageOptions }
| null;

interface PatternResult {
image: ImageResult;
options?: AddImageOptions;
}

interface AddImageOptions {
sdf?: boolean;
pixelRatio?: number;
}

export function setupStyleImageManager(
map: any,
options: StyleImageManagerOptions = {},
): () => void {
const { throwOnMissing = false } = options;
const styleImageMissing = (e) => {
loadStyleImage(map, e.id, options)
.catch((err) => {
if (throwOnMissing) {
throw err;
}
console.error(`Failed to load pattern image for ${e.id}:`, err);
})
.then(() => {});
};

// Register the event listener for missing images
map.on("styleimagemissing", styleImageMissing);
return () => {
// Clean up the event listener when the component unmounts
map.off("styleimagemissing", styleImageMissing);
};
}

async function loadStyleImage(
map: mapboxgl.Map,
id: string,
options: StyleImageManagerOptions = {},
) {
const { pixelRatio = 3 } = options;
const [prefix, name, ...rest] = id.split(":");

const { resolvers = defaultResolvers } = options;

// Match the prefix to a resolver function
if (prefix in resolvers) {
const resolver = resolvers[prefix];
const result = await resolver(prefix, [name, ...rest], options);

let image: ImageResult = null;
let addOptions: AddImageOptions = { pixelRatio };

if (result != null) {
if ("options" in result) {
addOptions = { ...addOptions, ...(result.options ?? {}) };
}
if ("image" in result) {
image = result.image;
addOptions = { ...addOptions, ...(result.options ?? {}) };
} else {
image = result;
}
}
if (image == null) {
throw new Error(`No image returned by resolver for pattern: ${id}`);
}
if (typeof image === "object" && "url" in image) {
await addImageURLToMap(map, id, image.url, addOptions);
} else {
addImageToMap(map, id, image, addOptions);
}
return;
}
}

enum SymbolImageFormat {
PNG = "png",
SVG = "svg",
}

function resolveLineSymbolImage(
id: string,
args: string[],
options: StyleImageManagerOptions = {},
) {
return resolveSymbolImage(
"geologic-symbols/lines/dev",
args,
SymbolImageFormat.PNG,
options,
);
}

function resolvePointSymbolImage(
id: string,
args: string[],
options: StyleImageManagerOptions = {},
) {
return resolveSymbolImage(
"geologic-symbols/points/strabospot",
args,
SymbolImageFormat.PNG,
options,
);
}

function resolveSymbolImage(
set: string,
args: string[],
format: SymbolImageFormat = SymbolImageFormat.PNG,
options: StyleImageManagerOptions = {},
) {
const { baseURL = "https://dev.macrostrat.org/assets/web" } = options;
const [name, ...rest] = args;
const lineSymbolsURL = `${baseURL}/${set}/${format}`;
return { url: lineSymbolsURL + `/${name}.${format}`, options: { sdf: true } };
}

export function addImageToMap(
map: mapboxgl.Map,
id: string,
image: HTMLImageElement | ImageData | null,
options: AddImageOptions,
) {
if (map.hasImage(id) || image == null) return;
map.addImage(id, image, options);
}

export async function addImageURLToMap(
map: mapboxgl.Map,
id: string,
url: string,
options: AddImageOptions,
) {
if (map.hasImage(id)) return;
const image = await loadImage(url);
addImageToMap(map, id, image, options);
}

async function resolveFGDCImage(
key: string,
args: string[],
options: StyleImageManagerOptions,
): Promise<PatternResult | null> {
const { baseURL = "https://dev.macrostrat.org/assets/web" } = options;
const [name, color, backgroundColor = "transparent"] = args;

const num = parseInt(name);
let patternName = name;
if (num == NaN) {
throw new Error(`Invalid FGDC pattern name: ${name}`);
}
if (num <= 599) {
// FGDC 1-599 are fill patterns
// Check if pattern ID has a suffix, or if not add one
patternName = `${num}-K`;
}

const image = (await createPatternImage({
patternURL: `${baseURL}/geologic-patterns/png/${patternName}.png`,
color: backgroundColor,
patternColor: color,
})) as ImageData;

return image;
}

async function resolveSolidColorImage(
key: string,
args: string[],
options: StyleImageManagerOptions,
): Promise<ImageData> {
return createSolidColorImage(args[0]);
}

export const defaultResolvers: Record<string, PatternResolverFunction> = {
fgdc: resolveFGDCImage,
color: resolveSolidColorImage,
"line-symbol": resolveLineSymbolImage,
point: resolvePointSymbolImage,
};
Loading
Loading