@kahwee/sf-map-svg 4.3.2 → 4.4.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/CHANGELOG.md +8 -0
- package/README.md +18 -1
- package/dist/src/explorer-core.js +23 -40
- package/dist/src/guide-data.js +1 -0
- package/dist/src/guide-static.js +1 -0
- package/dist/src/label-candidates.d.ts +24 -0
- package/dist/src/label-candidates.js +29 -0
- package/dist/src/label-renderer.d.ts +1 -10
- package/dist/src/map-core.d.ts +3 -1
- package/dist/src/map-core.js +7 -5
- package/dist/src/static-labels.d.ts +7 -0
- package/dist/src/static-labels.js +100 -0
- package/dist/src/static.js +12 -3
- package/dist/src/types.d.ts +7 -1
- package/docs/API.md +6 -2
- package/docs/EXAMPLES.md +1 -0
- package/docs/consumer-integration.md +43 -1
- package/docs/guide-bundle-report.md +4 -4
- package/llms-full.txt +106 -26
- package/llms.txt +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 4.4.0 — 2026-10-12
|
|
8
|
+
|
|
9
|
+
- Add opt-in static `labelLayout: { width }` to `renderMap`, `createGuideSVG`, and `createGuideShell`. Static and interactive maps now share overview priorities, neighborhood anchors, font sizes, and collision rules; centered labels retain their anchors during enhancement.
|
|
10
|
+
- Add static `neighborhoodLabels`, available flat or under `layers`, for the new layout. Guide SVGs and shells enable neighborhood names when opting in. Existing fixed-label output and interactive defaults remain unchanged; `labels: false` still hides text while preserving symbols and accessible titles.
|
|
11
|
+
- Keep static layout server-safe and dependency-free. The width is a CSS canvas-width hint, not the SVG projection width; portable font estimates can produce a different visible label set from browser-measured fonts. Document matching data, markers, colors, and layers, plus responsive-width and custom-style limitations.
|
|
12
|
+
- Keep server font estimation out of interactive-only imports and browser runtime code out of static imports. Refresh bundle reports and enforce bounded size budgets for the combined renderer and static-capable Pages tools.
|
|
13
|
+
- Add desktop and phone enhancement stories, collision and input-validation regressions, an SVG example, and copyable integration documentation. Existing screenshot baselines remain unchanged.
|
|
14
|
+
|
|
7
15
|
## 4.3.2 — 2026-10-10
|
|
8
16
|
|
|
9
17
|
- Update compatible development tooling and refresh dependency security fixes.
|
package/README.md
CHANGED
|
@@ -33,6 +33,23 @@ const { svg, project, viewBox } = renderMap(staticMapData, {
|
|
|
33
33
|
|
|
34
34
|
See [map options and animations](docs/API.md#static-maps) for optional layers and static animation.
|
|
35
35
|
|
|
36
|
+
## Labels before JavaScript loads
|
|
37
|
+
|
|
38
|
+
Version 4.4 adds an opt-in overview layout for server-rendered guides:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import { createGuideShell } from '@kahwee/sf-map-svg/guide/static';
|
|
42
|
+
|
|
43
|
+
const guideMarkup = createGuideShell({
|
|
44
|
+
title: 'Explore San Francisco',
|
|
45
|
+
labelLayout: { width: 390 },
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`width` is the expected canvas width in CSS pixels. The SVG keeps its 800 × 800 projection while labels share the interactive map's priorities, anchors, sizes and collision rules. Guide shells include neighborhood names in this layout; calls without `labelLayout` keep their previous output.
|
|
50
|
+
|
|
51
|
+
Enhance the shell with `mountGuideController()` using the same data, colors, markers and layers. Static font widths are estimated, so browser fonts and resize can change which labels fit. See the [complete integration recipe and width guidance](docs/consumer-integration.md#keep-overview-labels-consistent). No migration is required from 4.3.
|
|
52
|
+
|
|
36
53
|
## Interactive map
|
|
37
54
|
|
|
38
55
|
```ts
|
|
@@ -165,7 +182,7 @@ pnpm benchmark:markers --counts=2000 --browsers=chromium,firefox,webkit \
|
|
|
165
182
|
|
|
166
183
|
### Cached label preparation and renderer components
|
|
167
184
|
|
|
168
|
-
Neighborhood priority is sorted once per source. Styled label candidates are reused until selection, catalog, source, layers, appearance, or zoom/width eligibility changes. Overlay bounds are measured before marker and cluster SVG updates; secondary-road visibility and scale furniture skip unchanged writes. Marker catalog reconciliation and cluster rendering now have separate modules, reducing the explorer controller from 2,316 to 2,151 lines.
|
|
185
|
+
Neighborhood priority is sorted once per source. Styled label candidates are reused until selection, catalog, source, layers, appearance, or zoom/width eligibility changes. Overlay bounds are measured before marker and cluster SVG updates; secondary-road visibility and scale furniture skip unchanged writes. Marker catalog reconciliation and cluster rendering now have separate modules, reducing the explorer controller from 2,316 to 2,151 lines. At the 4.3.2 marker-performance baseline, the renderer was 34.6 KiB gzip and static rendering was 7.9 KiB. See the [current bundle report](docs/guide-bundle-report.md) for measurements including the opt-in static label layout.
|
|
169
186
|
|
|
170
187
|
A fresh Chromium comparison against `e774fbd` used 2,000 labeled markers, 20 operation samples, both widths, and all six frame workloads in basemap and neighborhood modes. Neighborhood-mode frame interval p95 values (milliseconds):
|
|
171
188
|
|
|
@@ -11,6 +11,7 @@ import { fitBounds, interiorAnchor, projectedBounds } from './explorer-layout.js
|
|
|
11
11
|
import { controlKeys, layerKeys, normalizeFeatures, validateSwitchPatch } from './features.js';
|
|
12
12
|
import { createFrameScheduler } from './frame-scheduler.js';
|
|
13
13
|
import { geometryPath } from './geometry.js';
|
|
14
|
+
import { labelCandidates, styleLabels, } from './label-candidates.js';
|
|
14
15
|
import { createLabelRenderer } from './label-renderer.js';
|
|
15
16
|
import { createLayerTransitions } from './layer-transition.js';
|
|
16
17
|
import { createSFMapWithData, districtColors, getLayerPathsWithData, resolveMapColors, } from './map-core.js';
|
|
@@ -881,46 +882,28 @@ export function createNeighborhoodExplorerCore(options = {}, data) {
|
|
|
881
882
|
name: selectedItem.feature.properties.canonicalName,
|
|
882
883
|
kind: 'selected',
|
|
883
884
|
});
|
|
884
|
-
|
|
885
|
-
candidates
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
(
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
]
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
return {
|
|
907
|
-
...item,
|
|
908
|
-
fontSize,
|
|
909
|
-
fontWeight: labelStyle.fontWeight ??
|
|
910
|
-
(item.kind.startsWith('selected') || item.kind === 'district' ? 700 : 550),
|
|
911
|
-
fill: colors.label ??
|
|
912
|
-
(item.kind === 'park'
|
|
913
|
-
? (colors.landmark ?? '#426641')
|
|
914
|
-
: item.kind === 'road'
|
|
915
|
-
? '#77736b'
|
|
916
|
-
: '#163d61'),
|
|
917
|
-
halo: labelStyle.haloColor ?? '#ffffff',
|
|
918
|
-
offset: item.kind === 'selected-marker'
|
|
919
|
-
? (marker?.marker.radius ?? markerRadius) + 9
|
|
920
|
-
: item.kind === 'bart'
|
|
921
|
-
? 11
|
|
922
|
-
: 0,
|
|
923
|
-
};
|
|
885
|
+
preparedLabels = styleLabels(labelCandidates({
|
|
886
|
+
selected: candidates,
|
|
887
|
+
districts: enabled('districtLabels') ? districtItems : [],
|
|
888
|
+
stations: enabled('bartStations') ? stationItems : [],
|
|
889
|
+
parks: enabled('landmarks') ? parkItems : [],
|
|
890
|
+
roads: enabled('roadLabels') ? roadItems : [],
|
|
891
|
+
neighborhoods: enabled('neighborhoodLabels')
|
|
892
|
+
? orderedNeighborhoods.flatMap((item) => item.feature !== selected && item.point
|
|
893
|
+
? [
|
|
894
|
+
{
|
|
895
|
+
point: item.point,
|
|
896
|
+
name: item.feature.properties.canonicalName,
|
|
897
|
+
kind: 'neighborhood',
|
|
898
|
+
},
|
|
899
|
+
]
|
|
900
|
+
: [])
|
|
901
|
+
: [],
|
|
902
|
+
}, width, zoom), {
|
|
903
|
+
colors,
|
|
904
|
+
labelStyle,
|
|
905
|
+
labelSize: { min: minLabelSize, max: maxLabelSize },
|
|
906
|
+
markerRadius: marker?.marker.radius ?? markerRadius,
|
|
924
907
|
});
|
|
925
908
|
return preparedLabels;
|
|
926
909
|
}
|
package/dist/src/guide-data.js
CHANGED
package/dist/src/guide-static.js
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { NeighborhoodExplorerOptions } from './types.js';
|
|
2
|
+
export interface LabelItem {
|
|
3
|
+
point: [number, number];
|
|
4
|
+
name: string;
|
|
5
|
+
kind: string;
|
|
6
|
+
level?: 'primary' | 'secondary';
|
|
7
|
+
}
|
|
8
|
+
export interface RenderLabel extends LabelItem {
|
|
9
|
+
fontSize: number;
|
|
10
|
+
fontWeight: number;
|
|
11
|
+
fill: string;
|
|
12
|
+
halo: string;
|
|
13
|
+
offset: number;
|
|
14
|
+
}
|
|
15
|
+
/** Shared priority and overview visibility for server and browser labels. */
|
|
16
|
+
export declare function labelCandidates(groups: {
|
|
17
|
+
selected: LabelItem[];
|
|
18
|
+
districts: LabelItem[];
|
|
19
|
+
stations: LabelItem[];
|
|
20
|
+
parks: LabelItem[];
|
|
21
|
+
roads: LabelItem[];
|
|
22
|
+
neighborhoods: LabelItem[];
|
|
23
|
+
}, width: number, zoom: number): LabelItem[];
|
|
24
|
+
export declare function styleLabels(candidates: LabelItem[], { colors, labelStyle, labelSize, markerRadius, }: Pick<NeighborhoodExplorerOptions, 'colors' | 'labelStyle' | 'labelSize' | 'markerRadius'>): RenderLabel[];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Shared priority and overview visibility for server and browser labels. */
|
|
2
|
+
export function labelCandidates(groups, width, zoom) {
|
|
3
|
+
return [
|
|
4
|
+
...groups.selected,
|
|
5
|
+
...groups.districts,
|
|
6
|
+
...(zoom >= 1.8 ? groups.stations : []),
|
|
7
|
+
...groups.parks.filter((park) => zoom >= 1.8 ||
|
|
8
|
+
park.name === 'Golden Gate Park' ||
|
|
9
|
+
(width >= 550 && park.name === 'Presidio')),
|
|
10
|
+
...groups.roads.filter((road) => road.level === 'primary' || zoom >= 1.8),
|
|
11
|
+
...groups.neighborhoods,
|
|
12
|
+
];
|
|
13
|
+
}
|
|
14
|
+
export function styleLabels(candidates, { colors = {}, labelStyle = {}, labelSize = {}, markerRadius = 6, }) {
|
|
15
|
+
return candidates.map((item) => ({
|
|
16
|
+
...item,
|
|
17
|
+
fontSize: Math.max(labelSize.min ?? 11, Math.min(labelSize.max ?? 12, item.kind === 'road' ? 11 : 12)),
|
|
18
|
+
fontWeight: labelStyle.fontWeight ??
|
|
19
|
+
(item.kind.startsWith('selected') || item.kind === 'district' ? 700 : 550),
|
|
20
|
+
fill: colors.label ??
|
|
21
|
+
(item.kind === 'park'
|
|
22
|
+
? (colors.landmark ?? '#426641')
|
|
23
|
+
: item.kind === 'road'
|
|
24
|
+
? '#77736b'
|
|
25
|
+
: '#163d61'),
|
|
26
|
+
halo: labelStyle.haloColor ?? '#ffffff',
|
|
27
|
+
offset: item.kind === 'selected-marker' ? markerRadius + 9 : item.kind === 'bart' ? 11 : 0,
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
@@ -1,14 +1,5 @@
|
|
|
1
1
|
import type { Bounds } from '../data/types.js';
|
|
2
|
-
|
|
3
|
-
point: [number, number];
|
|
4
|
-
name: string;
|
|
5
|
-
kind: string;
|
|
6
|
-
fontSize: number;
|
|
7
|
-
fontWeight: number;
|
|
8
|
-
fill: string;
|
|
9
|
-
halo: string;
|
|
10
|
-
offset: number;
|
|
11
|
-
}
|
|
2
|
+
import type { RenderLabel } from './label-candidates.js';
|
|
12
3
|
/** Reuse text nodes and screen-space metrics throughout a camera animation. */
|
|
13
4
|
export declare function createLabelRenderer(layer: SVGGElement): {
|
|
14
5
|
invalidateMetrics(): void;
|
package/dist/src/map-core.d.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { Geometry, Position } from '../data/types.js';
|
|
2
2
|
import type { DistrictYear, SFMapOptions } from './types.js';
|
|
3
3
|
export type { DistrictYear, MapMarker, MapOverlay, SFMapOptions } from './types.js';
|
|
4
|
+
import { type Project } from './geometry.js';
|
|
4
5
|
export interface SFMapData {
|
|
5
6
|
coast: Geometry;
|
|
6
7
|
districts?: Readonly<Partial<Record<DistrictYear, readonly DistrictRowData[]>>>;
|
|
7
8
|
neighborhoods?: readonly {
|
|
9
|
+
id?: string;
|
|
8
10
|
name: string;
|
|
9
11
|
geometry: Geometry;
|
|
10
12
|
}[];
|
|
@@ -95,7 +97,7 @@ export declare function resolveMapColors(theme: SFMapOptions['theme'], colors?:
|
|
|
95
97
|
selected: string;
|
|
96
98
|
};
|
|
97
99
|
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
98
|
-
export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData): {
|
|
100
|
+
export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData, renderLabels?: (project: Project) => string): {
|
|
99
101
|
svg: string;
|
|
100
102
|
project: (coordinates: Position) => [number, number];
|
|
101
103
|
viewBox: [number, number, number, number];
|
package/dist/src/map-core.js
CHANGED
|
@@ -110,7 +110,7 @@ export function resolveMapColors(theme, colors = {}) {
|
|
|
110
110
|
}
|
|
111
111
|
let sequence = 0;
|
|
112
112
|
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
113
|
-
export function createSFMapWithData(options, data) {
|
|
113
|
+
export function createSFMapWithData(options, data, renderLabels) {
|
|
114
114
|
const { theme = 'districts', width = 800, height = 800, year = 2022, districtLines = true, neighborhoodLines = false, districtFills = true, districtLabels = true, labels = true, highways = false, keyRoads = false, landmarks = false, bartStations = false, markers = [], title = 'San Francisco map', idPrefix = `sf-map-${++sequence}`, } = options;
|
|
115
115
|
validateMarkers(markers);
|
|
116
116
|
validateOverlays(options.overlays ?? []);
|
|
@@ -182,17 +182,19 @@ export function createSFMapWithData(options, data) {
|
|
|
182
182
|
if (districtLines)
|
|
183
183
|
parts.push(layers.districtLines(districtPaths, context));
|
|
184
184
|
parts.push(`<path data-layer="coastline" d="${coastPath}" ${stroke(colors.district, 0.65)}${animated ? ' pathLength="1"' : ''}/>`);
|
|
185
|
-
if (labels && districtLabels)
|
|
185
|
+
if (labels && !renderLabels && districtLabels)
|
|
186
186
|
parts.push(layers.districtLabels(districts, context));
|
|
187
|
-
if (labels && landmarks)
|
|
187
|
+
if (labels && !renderLabels && landmarks)
|
|
188
188
|
parts.push(layers.landmarkLabels(landmarkData, context));
|
|
189
|
-
if (labels && roadLabels)
|
|
189
|
+
if (labels && !renderLabels && roadLabels)
|
|
190
190
|
parts.push(layers.keyRoadLabels(roadData, context));
|
|
191
191
|
if (bartStations)
|
|
192
|
-
parts.push(layers.bartStations(stationData, context));
|
|
192
|
+
parts.push(layers.bartStations(stationData, { ...context, labels: labels && !renderLabels }));
|
|
193
193
|
for (const overlay of options.overlays ?? []) {
|
|
194
194
|
parts.push(`<path data-overlay-id="${escapeXml(overlay.id)}"${overlay.label ? ` aria-label="${escapeXml(overlay.label)}"` : ''} d="${path(overlay.geometry)}" fill="${escapeXml(overlay.fill ?? 'none')}" fill-opacity="${overlay.fillOpacity ?? 1}" stroke="${escapeXml(overlay.stroke ?? colors.road)}" stroke-width="${overlay.strokeWidth ?? 2}" vector-effect="non-scaling-stroke"${animated ? ' pathLength="1"' : ''}${overlay.visible === false ? ' display="none"' : ''}/>`);
|
|
195
195
|
}
|
|
196
|
+
if (labels && renderLabels)
|
|
197
|
+
parts.push(renderLabels(project));
|
|
196
198
|
parts.push(layers.markers(markers, context), '</g></svg>');
|
|
197
199
|
return {
|
|
198
200
|
svg: parts.join(''),
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Project } from './geometry.js';
|
|
2
|
+
import type { SFMapData } from './map-core.js';
|
|
3
|
+
import type { SFMapOptions } from './types.js';
|
|
4
|
+
/** Conservative portable metrics; the browser refines these with its actual font. */
|
|
5
|
+
export declare function estimateLabelWidth(text: string, fontSize: number): number;
|
|
6
|
+
/** Interactive overview rules, expressed entirely in screen pixels before serialization. */
|
|
7
|
+
export declare function staticLabels(data: SFMapData, options: SFMapOptions, project: Project): string;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { interiorAnchor, layoutLabels, projectedBounds } from './explorer-layout.js';
|
|
2
|
+
import { labelCandidates, styleLabels } from './label-candidates.js';
|
|
3
|
+
import { escapeXml, number } from './svg.js';
|
|
4
|
+
/** Conservative portable metrics; the browser refines these with its actual font. */
|
|
5
|
+
export function estimateLabelWidth(text, fontSize) {
|
|
6
|
+
return Array.from(text).reduce((width, char) => width +
|
|
7
|
+
(/[\u0300-\u036f]/u.test(char)
|
|
8
|
+
? 0
|
|
9
|
+
: /[\s.,:;'!|ilI]/u.test(char)
|
|
10
|
+
? 0.32
|
|
11
|
+
: /[MW@%]/u.test(char)
|
|
12
|
+
? 0.95
|
|
13
|
+
: (char.codePointAt(0) ?? 0) > 0x024f
|
|
14
|
+
? 1
|
|
15
|
+
: /[A-Z]/u.test(char)
|
|
16
|
+
? 0.72
|
|
17
|
+
: 0.6) *
|
|
18
|
+
fontSize, 0);
|
|
19
|
+
}
|
|
20
|
+
/** Interactive overview rules, expressed entirely in screen pixels before serialization. */
|
|
21
|
+
export function staticLabels(data, options, project) {
|
|
22
|
+
const width = options.labelLayout?.width ?? options.width ?? 800;
|
|
23
|
+
const unit = (options.width ?? 800) / width;
|
|
24
|
+
const height = (options.height ?? 800) / unit;
|
|
25
|
+
const point = (coordinates) => {
|
|
26
|
+
const [x, y] = project(coordinates);
|
|
27
|
+
return [x / unit, y / unit];
|
|
28
|
+
};
|
|
29
|
+
const markers = options.markers ?? [];
|
|
30
|
+
const marker = markers.find((item) => item.selected);
|
|
31
|
+
const neighborhoods = options.neighborhoodLabels
|
|
32
|
+
? (data.neighborhoods ?? [])
|
|
33
|
+
.map((item) => {
|
|
34
|
+
const bounds = projectedBounds(item.geometry, point);
|
|
35
|
+
return {
|
|
36
|
+
...item,
|
|
37
|
+
point: interiorAnchor(item.geometry, point),
|
|
38
|
+
area: (bounds[2] - bounds[0]) * (bounds[3] - bounds[1]),
|
|
39
|
+
};
|
|
40
|
+
})
|
|
41
|
+
.sort((a, b) => b.area - a.area || (a.id ?? a.name).localeCompare(b.id ?? b.name))
|
|
42
|
+
: [];
|
|
43
|
+
const districts = options.districtLabels !== false ? (data.districts?.[options.year ?? 2022] ?? []) : [];
|
|
44
|
+
for (const district of districts)
|
|
45
|
+
if (!Number.isFinite(district.id))
|
|
46
|
+
throw new TypeError('District IDs must be finite numbers.');
|
|
47
|
+
const candidates = styleLabels(labelCandidates({
|
|
48
|
+
selected: marker
|
|
49
|
+
? [
|
|
50
|
+
{
|
|
51
|
+
point: point([marker.lng, marker.lat]),
|
|
52
|
+
name: marker.label ?? marker.id,
|
|
53
|
+
kind: 'selected-marker',
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
: [],
|
|
57
|
+
districts: options.districtLabels !== false
|
|
58
|
+
? districts.flatMap((district) => (district.labelPoints.length ? district.labelPoints : [district.label]).map((label) => ({ point: point(label), name: String(district.id), kind: 'district' })))
|
|
59
|
+
: [],
|
|
60
|
+
stations: [], // Station names appear only after zooming; symbols remain at overview.
|
|
61
|
+
parks: options.landmarks
|
|
62
|
+
? (data.landmarks ?? []).map((item) => ({
|
|
63
|
+
point: point(item.label),
|
|
64
|
+
name: item.name,
|
|
65
|
+
kind: 'park',
|
|
66
|
+
}))
|
|
67
|
+
: [],
|
|
68
|
+
roads: (options.roadLabels ?? options.keyRoads)
|
|
69
|
+
? (data.keyRoads ?? []).map((item) => ({
|
|
70
|
+
point: point(item.label),
|
|
71
|
+
name: item.name,
|
|
72
|
+
level: item.level ?? 'primary',
|
|
73
|
+
kind: 'road',
|
|
74
|
+
}))
|
|
75
|
+
: [],
|
|
76
|
+
neighborhoods: neighborhoods.flatMap((item) => item.point ? [{ point: item.point, name: item.name, kind: 'neighborhood' }] : []),
|
|
77
|
+
}, width, 1), { colors: options.colors, markerRadius: marker?.radius ?? 6 });
|
|
78
|
+
const obstacles = [
|
|
79
|
+
...(options.bartStations
|
|
80
|
+
? (data.bartStations ?? []).map((item) => {
|
|
81
|
+
const [x, y] = point(item.coordinates);
|
|
82
|
+
return [x - 6, y - 6, x + 6, y + 6];
|
|
83
|
+
})
|
|
84
|
+
: []),
|
|
85
|
+
...markers.map((item) => {
|
|
86
|
+
const [x, y] = point([item.lng, item.lat]);
|
|
87
|
+
// Also cover the legacy SVG marker when it is larger than its interactive counterpart.
|
|
88
|
+
const radius = Math.max(item.radius ?? 6, (item.radius ?? (item.selected ? 8 : 5)) / unit) + 3;
|
|
89
|
+
return [x - radius, y - radius, x + radius, y + radius];
|
|
90
|
+
}),
|
|
91
|
+
];
|
|
92
|
+
const placed = layoutLabels(candidates.map((item) => ({
|
|
93
|
+
...item,
|
|
94
|
+
x: item.point[0],
|
|
95
|
+
y: item.point[1],
|
|
96
|
+
textWidth: estimateLabelWidth(item.name, item.fontSize),
|
|
97
|
+
textHeight: item.fontSize * 1.25,
|
|
98
|
+
})), width, height, obstacles);
|
|
99
|
+
return `<g data-layer="overview-labels" font-family="system-ui,sans-serif">${placed.map((item) => `<text data-label-kind="${escapeXml(item.kind)}" text-anchor="middle" x="${number((item.left + item.textWidth / 2) * unit)}" y="${number((item.top + item.fontSize) * unit)}" font-size="${number(item.fontSize * unit)}" font-weight="${item.fontWeight}" fill="${escapeXml(item.fill)}" stroke="${escapeXml(item.halo)}" stroke-width="${number(3 * unit)}" stroke-linejoin="round" paint-order="stroke">${escapeXml(item.name)}</text>`).join('')}</g>`;
|
|
100
|
+
}
|
package/dist/src/static.js
CHANGED
|
@@ -2,12 +2,13 @@ import { prepareAppearance } from './appearance.js';
|
|
|
2
2
|
import { validateSwitchPatch } from './features.js';
|
|
3
3
|
import { createSFMapWithData, getLayerPathsWithData } from './map-core.js';
|
|
4
4
|
import { normalizeStaticAnimation } from './static-animation.js';
|
|
5
|
+
import { staticLabels } from './static-labels.js';
|
|
5
6
|
import { assertOptions, validateMarkers, validateOverlays } from './validation.js';
|
|
6
7
|
/** Server-safe SVG rendering with explicit data; returns SVG plus projection helpers. */
|
|
7
8
|
export function renderMap(data, options = {}) {
|
|
8
9
|
const prepared = expandStaticOptions(options);
|
|
9
10
|
const { map, source } = staticInput(data, prepared);
|
|
10
|
-
return createSFMapWithData({ ...prepared, source }, map);
|
|
11
|
+
return createSFMapWithData({ ...prepared, source }, map, prepared.labelLayout ? (project) => staticLabels(map, prepared, project) : undefined);
|
|
11
12
|
}
|
|
12
13
|
/** Project canonical layer geometry without constructing an SVG string. */
|
|
13
14
|
export function getLayerPaths(data, options = {}) {
|
|
@@ -34,8 +35,9 @@ function staticInput(data, options) {
|
|
|
34
35
|
source,
|
|
35
36
|
map: {
|
|
36
37
|
...data.map,
|
|
37
|
-
neighborhoods: collection.features.map(({ geometry, properties }) => ({
|
|
38
|
-
|
|
38
|
+
neighborhoods: collection.features.map(({ id, geometry, properties }) => ({
|
|
39
|
+
id,
|
|
40
|
+
name: options.labelLayout ? properties.canonicalName : properties.sourceName,
|
|
39
41
|
geometry,
|
|
40
42
|
})),
|
|
41
43
|
},
|
|
@@ -46,6 +48,7 @@ const staticLayerKeys = [
|
|
|
46
48
|
'districtLines',
|
|
47
49
|
'districtLabels',
|
|
48
50
|
'neighborhoodLines',
|
|
51
|
+
'neighborhoodLabels',
|
|
49
52
|
'landmarks',
|
|
50
53
|
'bartStations',
|
|
51
54
|
'highways',
|
|
@@ -67,6 +70,7 @@ function expandStaticOptions(options) {
|
|
|
67
70
|
'year',
|
|
68
71
|
'source',
|
|
69
72
|
'labels',
|
|
73
|
+
'labelLayout',
|
|
70
74
|
'markers',
|
|
71
75
|
'overlays',
|
|
72
76
|
'title',
|
|
@@ -79,6 +83,11 @@ function expandStaticOptions(options) {
|
|
|
79
83
|
for (const key of ['title', 'idPrefix'])
|
|
80
84
|
if (options[key] !== undefined && typeof options[key] !== 'string')
|
|
81
85
|
throw new TypeError(`${key} must be a string.`);
|
|
86
|
+
if (options.labelLayout !== undefined) {
|
|
87
|
+
assertOptions(options.labelLayout, 'label layout', ['width']);
|
|
88
|
+
if (!Number.isFinite(options.labelLayout.width) || options.labelLayout.width <= 0)
|
|
89
|
+
throw new RangeError('Label layout width must be positive and finite.');
|
|
90
|
+
}
|
|
82
91
|
if (options.markers !== undefined)
|
|
83
92
|
validateMarkers(options.markers);
|
|
84
93
|
if (options.overlays !== undefined)
|
package/dist/src/types.d.ts
CHANGED
|
@@ -45,12 +45,18 @@ export interface SFMapOptions {
|
|
|
45
45
|
source?: NeighborhoodSource;
|
|
46
46
|
districtLines?: boolean;
|
|
47
47
|
neighborhoodLines?: boolean;
|
|
48
|
+
/** Neighborhood names in the opt-in collision layout. */
|
|
49
|
+
neighborhoodLabels?: boolean;
|
|
48
50
|
districtFills?: boolean;
|
|
49
51
|
/** Evaluated once per district at construction/style/year updates; call setDistrictStyle again when external data changes. */
|
|
50
52
|
districtStyle?: (district: import('./map-core.js').DistrictRowData) => DistrictStyle;
|
|
51
53
|
districtLabels?: boolean;
|
|
52
54
|
/** Hide all visible text labels while retaining geographic symbols and accessible titles. */
|
|
53
55
|
labels?: boolean;
|
|
56
|
+
/** Opt into interactive overview label priorities/collisions at this CSS display width. Uses estimated system-font metrics without a DOM. */
|
|
57
|
+
labelLayout?: {
|
|
58
|
+
width: number;
|
|
59
|
+
};
|
|
54
60
|
highways?: boolean;
|
|
55
61
|
keyRoads?: boolean;
|
|
56
62
|
roadLabels?: boolean;
|
|
@@ -73,7 +79,7 @@ export interface SFMapOptions {
|
|
|
73
79
|
colors?: Partial<Record<'water' | 'land' | 'district' | 'neighborhood' | 'highway' | 'road' | 'park' | 'landmark' | 'bart' | 'label' | 'marker' | 'selected', string>>;
|
|
74
80
|
}
|
|
75
81
|
/** Presentation supported by both the static and interactive renderers. */
|
|
76
|
-
export type StaticMapLayers =
|
|
82
|
+
export type StaticMapLayers = InteractiveLayers;
|
|
77
83
|
export type StaticMapAppearance = Pick<SFMapOptions, 'theme' | 'colors' | 'districtStyle'>;
|
|
78
84
|
/** Serializable viewport in the interactive map's fixed 800 × 800 projected space. */
|
|
79
85
|
export type MapViewport = readonly [x: number, y: number, size: number];
|
package/docs/API.md
CHANGED
|
@@ -19,9 +19,9 @@ const { svg } = renderMap(staticMapData, presentation);
|
|
|
19
19
|
const map = createMap(fullMapData, presentation);
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
Static `layers` supports every interactive layer switch
|
|
22
|
+
Static `layers` supports every interactive layer switch. `neighborhoodLabels` applies to the opt-in `labelLayout` described below. Static `appearance` supports `theme`, `colors`, and `districtStyle`; browser typography, area interaction styles, and screen-space marker sizing remain interactive settings. Unknown keys and invalid values are rejected for both flat and grouped options. Supplied grouped keys override flat compatibility keys; omitted or `undefined` grouped keys preserve the flat value or renderer default. Undefined color tokens use the theme default instead of entering SVG attributes. The two renderers retain their existing defaults. Marker arrays accept readonly inputs.
|
|
23
23
|
|
|
24
|
-
Flat compatibility options include `theme`, `width`, `height`, `padding`, `year` (2002, 2012, 2022), `districtLines`, `districtFills`, `districtStyle`, `districtLabels`, `neighborhoodLines`, `labels`, `highways`, `keyRoads`, `roadLabels`, `landmarks`, `bartStations`, `markers`, `overlays`, `title`, `idPrefix`, `colors`, and `animation`. Each optional layer is independent. User-supplied text and attributes are escaped in SVG output.
|
|
24
|
+
Flat compatibility options include `theme`, `width`, `height`, `padding`, `year` (2002, 2012, 2022), `districtLines`, `districtFills`, `districtStyle`, `districtLabels`, `neighborhoodLines`, `neighborhoodLabels`, `labels`, `labelLayout`, `highways`, `keyRoads`, `roadLabels`, `landmarks`, `bartStations`, `markers`, `overlays`, `title`, `idPrefix`, `colors`, and `animation`. Each optional layer is independent. User-supplied text and attributes are escaped in SVG output.
|
|
25
25
|
|
|
26
26
|
Custom district IDs must be finite numbers. An empty `labelPoints` array uses the district's primary `label` position.
|
|
27
27
|
|
|
@@ -29,6 +29,10 @@ The SVG description names the selected neighborhood source for source-aware data
|
|
|
29
29
|
|
|
30
30
|
`animation: true` (or `{ duration, delay }`) makes a static map draw itself with self-contained, scoped CSS: land fades in, the coast and lines draw, districts grow, then labels and points appear. It plays when the SVG is inserted into a page or loaded as an image, needs no JavaScript, and stays still under `prefers-reduced-motion`. Re-insert the markup to replay it. Default output is unchanged.
|
|
31
31
|
|
|
32
|
+
Static `labelLayout: { width: 390 }` opts into shared interactive **overview** label priorities, sizes, anchors and collision rules. `width` must be a positive finite CSS canvas width; it does not alter the SVG viewBox. Set `neighborhoodLabels: true` (flat or under `layers`) to add area names in this layout. Guide SVGs and shells enable neighborhood names by default only when opting into `labelLayout`. `labels: false` remains the master switch. Default static output is unchanged.
|
|
33
|
+
|
|
34
|
+
Static placement uses estimated system-font metrics without browser globals. Interactive placement measures actual fonts and includes live selection and HTML furniture, so exact glyph positions and the visible label set can differ. See the [integration recipe](consumer-integration.md#keep-overview-labels-consistent) for width and progressive-enhancement guidance.
|
|
35
|
+
|
|
32
36
|
## Interactive maps
|
|
33
37
|
|
|
34
38
|
`createMap` defaults to `mode: 'basemap'` and `appearance.theme: 'transit'`. Set the mode explicitly for neighborhood or district exploration. District and neighborhood layer defaults follow the mode; other supplied layers default on. The [developer guide](developer-guide.md) compares static and interactive defaults and explains configuration resets, coordinates, and cleanup.
|
package/docs/EXAMPLES.md
CHANGED
|
@@ -7,6 +7,7 @@ Choose by task. All snippets use the public package API; browser examples need a
|
|
|
7
7
|
| I want to… | Start here |
|
|
8
8
|
| --- | --- |
|
|
9
9
|
| Render an SVG on a server | [Static map](#static-svg) |
|
|
10
|
+
| Show labels before JavaScript loads | [Static-to-interactive guide](consumer-integration.md#keep-overview-labels-consistent) |
|
|
10
11
|
| Add a small map to a browser | [Lightweight guide](#lightweight-interactive-guide) |
|
|
11
12
|
| Bundle only selected geography | [Selected data](#selected-geography) |
|
|
12
13
|
| Draw a route over the city | [Route overlay](#route-overlay) |
|
|
@@ -15,6 +15,46 @@ const markup = createGuideShell({ title: 'Explore San Francisco' });
|
|
|
15
15
|
|
|
16
16
|
The shell uses an 800 × 800 projection with 28 units of padding, matching the interactive map. Use `createGuideSVG()` for custom dimensions. Rendering is offline, with zero runtime dependencies.
|
|
17
17
|
|
|
18
|
+
## Keep overview labels consistent
|
|
19
|
+
|
|
20
|
+
Opt into the browser's overview label priorities and collision rules with a target **canvas width in CSS pixels**:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
const labeledMarkup = createGuideShell({
|
|
24
|
+
title: 'Explore San Francisco',
|
|
25
|
+
labelLayout: { width: 390 },
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This keeps the 800 × 800 projection. It sizes and places text for a 390 px canvas, prioritizes major parks and primary roads, includes neighborhood names, and avoids label, marker, and BART-symbol collisions. BART names and smaller parks remain deferred until interactive zoom. Use the same geography, colors, markers and layer switches when mounting. Set `layers.neighborhoodLabels: false` on both renderers to omit neighborhood names.
|
|
30
|
+
|
|
31
|
+
The width is a rendering hint, not a responsive script. Generate an appropriate variant for your known embed width; an SVG displayed at another width scales its labels with the artwork. Static layout uses conservative estimated system-font widths, while the browser measures actual fonts and recalculates on resize. Font metrics, custom browser label styles, selection, clustering and HTML overlays can therefore change the final label set or position. This reduces the transition without promising pixel-identical hydration. Existing calls without `labelLayout` keep their original fixed labels.
|
|
32
|
+
|
|
33
|
+
For a fixed-width embed, use shared inputs on the server and in the browser:
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// Shared inputs: coordinates, IDs, radii, colors and layer choices must match.
|
|
37
|
+
const guideMarkers = [
|
|
38
|
+
{ id: 'ferry', label: 'Ferry Building', lng: -122.3937, lat: 37.7955, radius: 7 },
|
|
39
|
+
];
|
|
40
|
+
const guideLayers = { neighborhoodLabels: true, roadLabels: true };
|
|
41
|
+
const guideColors = { label: '#183e58', landmark: '#426641' };
|
|
42
|
+
|
|
43
|
+
// Server: place this HTML in a canvas that is 390 CSS pixels wide.
|
|
44
|
+
const labeledPlacesMarkup = createGuideShell({
|
|
45
|
+
labelLayout: { width: 390 },
|
|
46
|
+
markers: guideMarkers,
|
|
47
|
+
layers: guideLayers,
|
|
48
|
+
appearance: { colors: guideColors },
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
After inserting that markup, pass `markers: guideMarkers`, `layers: guideLayers`, and `appearance: { colors: guideColors }` to `mountGuideController()` as shown below. The static-only `labelLayout` option is not a browser option: the controller measures the actual canvas and font. The shell border is outside the canvas, so a default shell with a 390 px outer width has a 388 px canvas.
|
|
53
|
+
|
|
54
|
+
For responsive layouts whose canvas width is unknown at build time, choose a representative width and accept text scaling until enhancement, or serve independently rendered SVG variants through your application's responsive markup. The package does not detect a device on the server. Keep `labels: false` if your design requires no visible text change at enhancement.
|
|
55
|
+
|
|
56
|
+
For `renderMap()`, also enable `neighborhoodLabels` explicitly; it defaults off outside the guide preset. `labels: false` hides all text in either layout while preserving symbols and accessible titles.
|
|
57
|
+
|
|
18
58
|
## Enhance the existing frame
|
|
19
59
|
|
|
20
60
|
In your browser entry, mount the controller into that shell:
|
|
@@ -27,7 +67,9 @@ if (!shell) throw new Error('Missing guide shell');
|
|
|
27
67
|
const guide = mountGuideController(shell, {
|
|
28
68
|
attribution: 'compact',
|
|
29
69
|
features: { motion: { duration: 400 }, markerEntrance: true },
|
|
30
|
-
|
|
70
|
+
markers: guideMarkers,
|
|
71
|
+
layers: guideLayers,
|
|
72
|
+
appearance: { theme: 'transit', colors: guideColors },
|
|
31
73
|
});
|
|
32
74
|
|
|
33
75
|
// Call before your application removes the containing view.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Guide bundle size report
|
|
2
2
|
|
|
3
|
-
Generated 2026-10-
|
|
3
|
+
Generated 2026-10-13 by `pnpm report:guide` with Vite production minification and gzip compression. Each emitted JS chunk is compressed independently. The report measures the explicit-data root and the optional `@kahwee/sf-map-svg/guide` preset.
|
|
4
4
|
|
|
5
5
|
| Entry | Initial JS, raw | Initial JS, gzip | Explicit detail JS, gzip |
|
|
6
6
|
| --- | ---: | ---: | ---: |
|
|
7
|
-
| Renderer root (explicit data) |
|
|
8
|
-
| Static renderer |
|
|
9
|
-
| Guide preset |
|
|
7
|
+
| Renderer root (explicit data) | 122.2 KB | 35.7 KB | — |
|
|
8
|
+
| Static renderer | 31.4 KB | 9.7 KB | — |
|
|
9
|
+
| Guide preset | 508.3 KB | 122.1 KB | 467.0 KB |
|
|
10
10
|
|
|
11
11
|
**500 KB target:** met.
|
|
12
12
|
|
package/llms-full.txt
CHANGED
|
@@ -2,13 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
> Offline San Francisco SVG maps with explicit geographic imports, no runtime dependencies, static rendering, and a browser controller.
|
|
4
4
|
|
|
5
|
-
@kahwee/sf-map-svg 4.
|
|
5
|
+
@kahwee/sf-map-svg 4.4.0 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
|
|
6
6
|
|
|
7
7
|
These are working-tree docs. Unreleased changelog entries may describe APIs absent from npm; verify the installed version before copying them.
|
|
8
8
|
|
|
9
9
|
Use renderMap(data, options) for SVG strings; createMap(data, options) returns a controller. Mount map.element and destroy it on unmount. Choose mode explicitly: createMap defaults to basemap. Geography is separate; staticMapData and fullMapData have different shapes.
|
|
10
10
|
|
|
11
|
-
<!-- Generated from README.md; @kahwee/sf-map-svg 4.
|
|
11
|
+
<!-- Generated from README.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
|
|
12
12
|
|
|
13
13
|
# SF Map SVG
|
|
14
14
|
|
|
@@ -45,6 +45,23 @@ const { svg, project, viewBox } = renderMap(staticMapData, {
|
|
|
45
45
|
|
|
46
46
|
See [map options and animations](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md#static-maps) for optional layers and static animation.
|
|
47
47
|
|
|
48
|
+
## Labels before JavaScript loads
|
|
49
|
+
|
|
50
|
+
Version 4.4 adds an opt-in overview layout for server-rendered guides:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
import { createGuideShell } from '@kahwee/sf-map-svg/guide/static';
|
|
54
|
+
|
|
55
|
+
const guideMarkup = createGuideShell({
|
|
56
|
+
title: 'Explore San Francisco',
|
|
57
|
+
labelLayout: { width: 390 },
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`width` is the expected canvas width in CSS pixels. The SVG keeps its 800 × 800 projection while labels share the interactive map's priorities, anchors, sizes and collision rules. Guide shells include neighborhood names in this layout; calls without `labelLayout` keep their previous output.
|
|
62
|
+
|
|
63
|
+
Enhance the shell with `mountGuideController()` using the same data, colors, markers and layers. Static font widths are estimated, so browser fonts and resize can change which labels fit. See the [complete integration recipe and width guidance](https://github.com/kahwee/sf-map-svg/blob/main/docs/consumer-integration.md#keep-overview-labels-consistent). No migration is required from 4.3.
|
|
64
|
+
|
|
48
65
|
## Interactive map
|
|
49
66
|
|
|
50
67
|
```ts
|
|
@@ -177,7 +194,7 @@ pnpm benchmark:markers --counts=2000 --browsers=chromium,firefox,webkit \
|
|
|
177
194
|
|
|
178
195
|
### Cached label preparation and renderer components
|
|
179
196
|
|
|
180
|
-
Neighborhood priority is sorted once per source. Styled label candidates are reused until selection, catalog, source, layers, appearance, or zoom/width eligibility changes. Overlay bounds are measured before marker and cluster SVG updates; secondary-road visibility and scale furniture skip unchanged writes. Marker catalog reconciliation and cluster rendering now have separate modules, reducing the explorer controller from 2,316 to 2,151 lines.
|
|
197
|
+
Neighborhood priority is sorted once per source. Styled label candidates are reused until selection, catalog, source, layers, appearance, or zoom/width eligibility changes. Overlay bounds are measured before marker and cluster SVG updates; secondary-road visibility and scale furniture skip unchanged writes. Marker catalog reconciliation and cluster rendering now have separate modules, reducing the explorer controller from 2,316 to 2,151 lines. At the 4.3.2 marker-performance baseline, the renderer was 34.6 KiB gzip and static rendering was 7.9 KiB. See the [current bundle report](https://github.com/kahwee/sf-map-svg/blob/main/docs/guide-bundle-report.md) for measurements including the opt-in static label layout.
|
|
181
198
|
|
|
182
199
|
A fresh Chromium comparison against `e774fbd` used 2,000 labeled markers, 20 operation samples, both widths, and all six frame workloads in basemap and neighborhood modes. Neighborhood-mode frame interval p95 values (milliseconds):
|
|
183
200
|
|
|
@@ -203,7 +220,7 @@ pnpm benchmark:markers --counts=2000 --mode=neighborhoods \
|
|
|
203
220
|
|
|
204
221
|
---
|
|
205
222
|
|
|
206
|
-
<!-- Generated from docs/developer-guide.md; @kahwee/sf-map-svg 4.
|
|
223
|
+
<!-- Generated from docs/developer-guide.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
|
|
207
224
|
|
|
208
225
|
# Developer guide
|
|
209
226
|
|
|
@@ -347,7 +364,7 @@ See [API options](https://github.com/kahwee/sf-map-svg/blob/main/docs/API.md), [
|
|
|
347
364
|
|
|
348
365
|
---
|
|
349
366
|
|
|
350
|
-
<!-- Generated from docs/API.md; @kahwee/sf-map-svg 4.
|
|
367
|
+
<!-- Generated from docs/API.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
|
|
351
368
|
|
|
352
369
|
# Map options and lifecycle
|
|
353
370
|
|
|
@@ -370,9 +387,9 @@ const { svg } = renderMap(staticMapData, presentation);
|
|
|
370
387
|
const map = createMap(fullMapData, presentation);
|
|
371
388
|
```
|
|
372
389
|
|
|
373
|
-
Static `layers` supports every interactive layer switch
|
|
390
|
+
Static `layers` supports every interactive layer switch. `neighborhoodLabels` applies to the opt-in `labelLayout` described below. Static `appearance` supports `theme`, `colors`, and `districtStyle`; browser typography, area interaction styles, and screen-space marker sizing remain interactive settings. Unknown keys and invalid values are rejected for both flat and grouped options. Supplied grouped keys override flat compatibility keys; omitted or `undefined` grouped keys preserve the flat value or renderer default. Undefined color tokens use the theme default instead of entering SVG attributes. The two renderers retain their existing defaults. Marker arrays accept readonly inputs.
|
|
374
391
|
|
|
375
|
-
Flat compatibility options include `theme`, `width`, `height`, `padding`, `year` (2002, 2012, 2022), `districtLines`, `districtFills`, `districtStyle`, `districtLabels`, `neighborhoodLines`, `labels`, `highways`, `keyRoads`, `roadLabels`, `landmarks`, `bartStations`, `markers`, `overlays`, `title`, `idPrefix`, `colors`, and `animation`. Each optional layer is independent. User-supplied text and attributes are escaped in SVG output.
|
|
392
|
+
Flat compatibility options include `theme`, `width`, `height`, `padding`, `year` (2002, 2012, 2022), `districtLines`, `districtFills`, `districtStyle`, `districtLabels`, `neighborhoodLines`, `neighborhoodLabels`, `labels`, `labelLayout`, `highways`, `keyRoads`, `roadLabels`, `landmarks`, `bartStations`, `markers`, `overlays`, `title`, `idPrefix`, `colors`, and `animation`. Each optional layer is independent. User-supplied text and attributes are escaped in SVG output.
|
|
376
393
|
|
|
377
394
|
Custom district IDs must be finite numbers. An empty `labelPoints` array uses the district's primary `label` position.
|
|
378
395
|
|
|
@@ -380,6 +397,10 @@ The SVG description names the selected neighborhood source for source-aware data
|
|
|
380
397
|
|
|
381
398
|
`animation: true` (or `{ duration, delay }`) makes a static map draw itself with self-contained, scoped CSS: land fades in, the coast and lines draw, districts grow, then labels and points appear. It plays when the SVG is inserted into a page or loaded as an image, needs no JavaScript, and stays still under `prefers-reduced-motion`. Re-insert the markup to replay it. Default output is unchanged.
|
|
382
399
|
|
|
400
|
+
Static `labelLayout: { width: 390 }` opts into shared interactive **overview** label priorities, sizes, anchors and collision rules. `width` must be a positive finite CSS canvas width; it does not alter the SVG viewBox. Set `neighborhoodLabels: true` (flat or under `layers`) to add area names in this layout. Guide SVGs and shells enable neighborhood names by default only when opting into `labelLayout`. `labels: false` remains the master switch. Default static output is unchanged.
|
|
401
|
+
|
|
402
|
+
Static placement uses estimated system-font metrics without browser globals. Interactive placement measures actual fonts and includes live selection and HTML furniture, so exact glyph positions and the visible label set can differ. See the [integration recipe](https://github.com/kahwee/sf-map-svg/blob/main/docs/consumer-integration.md#keep-overview-labels-consistent) for width and progressive-enhancement guidance.
|
|
403
|
+
|
|
383
404
|
## Interactive maps
|
|
384
405
|
|
|
385
406
|
`createMap` defaults to `mode: 'basemap'` and `appearance.theme: 'transit'`. Set the mode explicitly for neighborhood or district exploration. District and neighborhood layer defaults follow the mode; other supplied layers default on. The [developer guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/developer-guide.md) compares static and interactive defaults and explains configuration resets, coordinates, and cleanup.
|
|
@@ -459,7 +480,7 @@ Explore presentation options in the [live map design playground](https://kahwee.
|
|
|
459
480
|
|
|
460
481
|
---
|
|
461
482
|
|
|
462
|
-
<!-- Generated from docs/EXAMPLES.md; @kahwee/sf-map-svg 4.
|
|
483
|
+
<!-- Generated from docs/EXAMPLES.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
|
|
463
484
|
|
|
464
485
|
# Examples
|
|
465
486
|
|
|
@@ -470,6 +491,7 @@ Choose by task. All snippets use the public package API; browser examples need a
|
|
|
470
491
|
| I want to… | Start here |
|
|
471
492
|
| --- | --- |
|
|
472
493
|
| Render an SVG on a server | [Static map](#static-svg) |
|
|
494
|
+
| Show labels before JavaScript loads | [Static-to-interactive guide](https://github.com/kahwee/sf-map-svg/blob/main/docs/consumer-integration.md#keep-overview-labels-consistent) |
|
|
473
495
|
| Add a small map to a browser | [Lightweight guide](#lightweight-interactive-guide) |
|
|
474
496
|
| Bundle only selected geography | [Selected data](#selected-geography) |
|
|
475
497
|
| Draw a route over the city | [Route overlay](#route-overlay) |
|
|
@@ -570,7 +592,7 @@ For the controller and explicit data imports, see [the v3 migration guide](https
|
|
|
570
592
|
|
|
571
593
|
---
|
|
572
594
|
|
|
573
|
-
<!-- Generated from docs/consumer-integration.md; @kahwee/sf-map-svg 4.
|
|
595
|
+
<!-- Generated from docs/consumer-integration.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
|
|
574
596
|
|
|
575
597
|
# Embedding and progressive enhancement
|
|
576
598
|
|
|
@@ -589,6 +611,46 @@ const markup = createGuideShell({ title: 'Explore San Francisco' });
|
|
|
589
611
|
|
|
590
612
|
The shell uses an 800 × 800 projection with 28 units of padding, matching the interactive map. Use `createGuideSVG()` for custom dimensions. Rendering is offline, with zero runtime dependencies.
|
|
591
613
|
|
|
614
|
+
## Keep overview labels consistent
|
|
615
|
+
|
|
616
|
+
Opt into the browser's overview label priorities and collision rules with a target **canvas width in CSS pixels**:
|
|
617
|
+
|
|
618
|
+
```ts
|
|
619
|
+
const labeledMarkup = createGuideShell({
|
|
620
|
+
title: 'Explore San Francisco',
|
|
621
|
+
labelLayout: { width: 390 },
|
|
622
|
+
});
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
This keeps the 800 × 800 projection. It sizes and places text for a 390 px canvas, prioritizes major parks and primary roads, includes neighborhood names, and avoids label, marker, and BART-symbol collisions. BART names and smaller parks remain deferred until interactive zoom. Use the same geography, colors, markers and layer switches when mounting. Set `layers.neighborhoodLabels: false` on both renderers to omit neighborhood names.
|
|
626
|
+
|
|
627
|
+
The width is a rendering hint, not a responsive script. Generate an appropriate variant for your known embed width; an SVG displayed at another width scales its labels with the artwork. Static layout uses conservative estimated system-font widths, while the browser measures actual fonts and recalculates on resize. Font metrics, custom browser label styles, selection, clustering and HTML overlays can therefore change the final label set or position. This reduces the transition without promising pixel-identical hydration. Existing calls without `labelLayout` keep their original fixed labels.
|
|
628
|
+
|
|
629
|
+
For a fixed-width embed, use shared inputs on the server and in the browser:
|
|
630
|
+
|
|
631
|
+
```ts
|
|
632
|
+
// Shared inputs: coordinates, IDs, radii, colors and layer choices must match.
|
|
633
|
+
const guideMarkers = [
|
|
634
|
+
{ id: 'ferry', label: 'Ferry Building', lng: -122.3937, lat: 37.7955, radius: 7 },
|
|
635
|
+
];
|
|
636
|
+
const guideLayers = { neighborhoodLabels: true, roadLabels: true };
|
|
637
|
+
const guideColors = { label: '#183e58', landmark: '#426641' };
|
|
638
|
+
|
|
639
|
+
// Server: place this HTML in a canvas that is 390 CSS pixels wide.
|
|
640
|
+
const labeledPlacesMarkup = createGuideShell({
|
|
641
|
+
labelLayout: { width: 390 },
|
|
642
|
+
markers: guideMarkers,
|
|
643
|
+
layers: guideLayers,
|
|
644
|
+
appearance: { colors: guideColors },
|
|
645
|
+
});
|
|
646
|
+
```
|
|
647
|
+
|
|
648
|
+
After inserting that markup, pass `markers: guideMarkers`, `layers: guideLayers`, and `appearance: { colors: guideColors }` to `mountGuideController()` as shown below. The static-only `labelLayout` option is not a browser option: the controller measures the actual canvas and font. The shell border is outside the canvas, so a default shell with a 390 px outer width has a 388 px canvas.
|
|
649
|
+
|
|
650
|
+
For responsive layouts whose canvas width is unknown at build time, choose a representative width and accept text scaling until enhancement, or serve independently rendered SVG variants through your application's responsive markup. The package does not detect a device on the server. Keep `labels: false` if your design requires no visible text change at enhancement.
|
|
651
|
+
|
|
652
|
+
For `renderMap()`, also enable `neighborhoodLabels` explicitly; it defaults off outside the guide preset. `labels: false` hides all text in either layout while preserving symbols and accessible titles.
|
|
653
|
+
|
|
592
654
|
## Enhance the existing frame
|
|
593
655
|
|
|
594
656
|
In your browser entry, mount the controller into that shell:
|
|
@@ -601,7 +663,9 @@ if (!shell) throw new Error('Missing guide shell');
|
|
|
601
663
|
const guide = mountGuideController(shell, {
|
|
602
664
|
attribution: 'compact',
|
|
603
665
|
features: { motion: { duration: 400 }, markerEntrance: true },
|
|
604
|
-
|
|
666
|
+
markers: guideMarkers,
|
|
667
|
+
layers: guideLayers,
|
|
668
|
+
appearance: { theme: 'transit', colors: guideColors },
|
|
605
669
|
});
|
|
606
670
|
|
|
607
671
|
// Call before your application removes the containing view.
|
|
@@ -622,7 +686,7 @@ See the [exact API contract](https://github.com/kahwee/sf-map-svg/blob/main/docs
|
|
|
622
686
|
|
|
623
687
|
---
|
|
624
688
|
|
|
625
|
-
<!-- Generated from docs/migration-v3.md; @kahwee/sf-map-svg 4.
|
|
689
|
+
<!-- Generated from docs/migration-v3.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
|
|
626
690
|
|
|
627
691
|
# Migrate to version 3
|
|
628
692
|
|
|
@@ -663,7 +727,7 @@ District styles are now prepared once per district on construction and style/yea
|
|
|
663
727
|
|
|
664
728
|
---
|
|
665
729
|
|
|
666
|
-
<!-- Generated from SOURCES.md; @kahwee/sf-map-svg 4.
|
|
730
|
+
<!-- Generated from SOURCES.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
|
|
667
731
|
|
|
668
732
|
# Geometry and provenance
|
|
669
733
|
|
|
@@ -847,7 +911,7 @@ display geometry, and no precinct polygons or raw workbook data are shipped.
|
|
|
847
911
|
|
|
848
912
|
---
|
|
849
913
|
|
|
850
|
-
<!-- Generated from CHANGELOG.md; @kahwee/sf-map-svg 4.
|
|
914
|
+
<!-- Generated from CHANGELOG.md; @kahwee/sf-map-svg 4.4.0 · local working tree. -->
|
|
851
915
|
|
|
852
916
|
# Changelog
|
|
853
917
|
|
|
@@ -855,6 +919,14 @@ User-visible changes are recorded here. Unreleased entries describe changes on `
|
|
|
855
919
|
|
|
856
920
|
## Unreleased
|
|
857
921
|
|
|
922
|
+
## 4.4.0 — 2026-10-12
|
|
923
|
+
|
|
924
|
+
- Add opt-in static `labelLayout: { width }` to `renderMap`, `createGuideSVG`, and `createGuideShell`. Static and interactive maps now share overview priorities, neighborhood anchors, font sizes, and collision rules; centered labels retain their anchors during enhancement.
|
|
925
|
+
- Add static `neighborhoodLabels`, available flat or under `layers`, for the new layout. Guide SVGs and shells enable neighborhood names when opting in. Existing fixed-label output and interactive defaults remain unchanged; `labels: false` still hides text while preserving symbols and accessible titles.
|
|
926
|
+
- Keep static layout server-safe and dependency-free. The width is a CSS canvas-width hint, not the SVG projection width; portable font estimates can produce a different visible label set from browser-measured fonts. Document matching data, markers, colors, and layers, plus responsive-width and custom-style limitations.
|
|
927
|
+
- Keep server font estimation out of interactive-only imports and browser runtime code out of static imports. Refresh bundle reports and enforce bounded size budgets for the combined renderer and static-capable Pages tools.
|
|
928
|
+
- Add desktop and phone enhancement stories, collision and input-validation regressions, an SVG example, and copyable integration documentation. Existing screenshot baselines remain unchanged.
|
|
929
|
+
|
|
858
930
|
## 4.3.2 — 2026-10-10
|
|
859
931
|
|
|
860
932
|
- Update compatible development tooling and refresh dependency security fixes.
|
|
@@ -1191,7 +1263,7 @@ consistent home, and makes animation and subscription ownership predictable.
|
|
|
1191
1263
|
|
|
1192
1264
|
# Published type contracts
|
|
1193
1265
|
|
|
1194
|
-
@kahwee/sf-map-svg 4.
|
|
1266
|
+
@kahwee/sf-map-svg 4.4.0 · local working tree. These declarations are generated by TypeScript. Use package export paths, not the internal filenames below. Imported relative contracts are included for context, not as additional public entry points.
|
|
1195
1267
|
|
|
1196
1268
|
| Public import | Declaration |
|
|
1197
1269
|
| --- | --- |
|
|
@@ -1608,12 +1680,18 @@ export interface SFMapOptions {
|
|
|
1608
1680
|
source?: NeighborhoodSource;
|
|
1609
1681
|
districtLines?: boolean;
|
|
1610
1682
|
neighborhoodLines?: boolean;
|
|
1683
|
+
/** Neighborhood names in the opt-in collision layout. */
|
|
1684
|
+
neighborhoodLabels?: boolean;
|
|
1611
1685
|
districtFills?: boolean;
|
|
1612
1686
|
/** Evaluated once per district at construction/style/year updates; call setDistrictStyle again when external data changes. */
|
|
1613
1687
|
districtStyle?: (district: import('./map-core.js').DistrictRowData) => DistrictStyle;
|
|
1614
1688
|
districtLabels?: boolean;
|
|
1615
1689
|
/** Hide all visible text labels while retaining geographic symbols and accessible titles. */
|
|
1616
1690
|
labels?: boolean;
|
|
1691
|
+
/** Opt into interactive overview label priorities/collisions at this CSS display width. Uses estimated system-font metrics without a DOM. */
|
|
1692
|
+
labelLayout?: {
|
|
1693
|
+
width: number;
|
|
1694
|
+
};
|
|
1617
1695
|
highways?: boolean;
|
|
1618
1696
|
keyRoads?: boolean;
|
|
1619
1697
|
roadLabels?: boolean;
|
|
@@ -1636,7 +1714,7 @@ export interface SFMapOptions {
|
|
|
1636
1714
|
colors?: Partial<Record<'water' | 'land' | 'district' | 'neighborhood' | 'highway' | 'road' | 'park' | 'landmark' | 'bart' | 'label' | 'marker' | 'selected', string>>;
|
|
1637
1715
|
}
|
|
1638
1716
|
/** Presentation supported by both the static and interactive renderers. */
|
|
1639
|
-
export type StaticMapLayers =
|
|
1717
|
+
export type StaticMapLayers = InteractiveLayers;
|
|
1640
1718
|
export type StaticMapAppearance = Pick<SFMapOptions, 'theme' | 'colors' | 'districtStyle'>;
|
|
1641
1719
|
/** Serializable viewport in the interactive map's fixed 800 × 800 projected space. */
|
|
1642
1720
|
export type MapViewport = readonly [x: number, y: number, size: number];
|
|
@@ -1839,10 +1917,12 @@ export interface InteractiveSFMapData {
|
|
|
1839
1917
|
import type { Geometry, Position } from '../data/types.js';
|
|
1840
1918
|
import type { DistrictYear, SFMapOptions } from './types.js';
|
|
1841
1919
|
export type { DistrictYear, MapMarker, MapOverlay, SFMapOptions } from './types.js';
|
|
1920
|
+
import { type Project } from './geometry.js';
|
|
1842
1921
|
export interface SFMapData {
|
|
1843
1922
|
coast: Geometry;
|
|
1844
1923
|
districts?: Readonly<Partial<Record<DistrictYear, readonly DistrictRowData[]>>>;
|
|
1845
1924
|
neighborhoods?: readonly {
|
|
1925
|
+
id?: string;
|
|
1846
1926
|
name: string;
|
|
1847
1927
|
geometry: Geometry;
|
|
1848
1928
|
}[];
|
|
@@ -1933,13 +2013,23 @@ export declare function resolveMapColors(theme: SFMapOptions['theme'], colors?:
|
|
|
1933
2013
|
selected: string;
|
|
1934
2014
|
};
|
|
1935
2015
|
/** Make an offline SVG and the matching longitude/latitude projection. */
|
|
1936
|
-
export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData): {
|
|
2016
|
+
export declare function createSFMapWithData(options: SFMapOptions, data: SFMapData, renderLabels?: (project: Project) => string): {
|
|
1937
2017
|
svg: string;
|
|
1938
2018
|
project: (coordinates: Position) => [number, number];
|
|
1939
2019
|
viewBox: [number, number, number, number];
|
|
1940
2020
|
};
|
|
1941
2021
|
```
|
|
1942
2022
|
|
|
2023
|
+
### dist/src/geometry.d.ts
|
|
2024
|
+
|
|
2025
|
+
```ts
|
|
2026
|
+
import type { Geometry, Position } from '../data/types.js';
|
|
2027
|
+
export type Project = (position: Position) => [number, number];
|
|
2028
|
+
export declare function rawProject([longitude, latitude]: Position): [number, number];
|
|
2029
|
+
export declare function positions(geometry: Geometry | null | undefined): Position[];
|
|
2030
|
+
export declare function geometryPath(geometry: Geometry | null | undefined, project: Project): string;
|
|
2031
|
+
```
|
|
2032
|
+
|
|
1943
2033
|
### dist/src/static.d.ts
|
|
1944
2034
|
|
|
1945
2035
|
```ts
|
|
@@ -2116,16 +2206,6 @@ import type { FeatureCollection, NeighborhoodProperties } from './types.js';
|
|
|
2116
2206
|
export declare const sfFindNeighborhoods: FeatureCollection<NeighborhoodProperties>;
|
|
2117
2207
|
```
|
|
2118
2208
|
|
|
2119
|
-
### ./dist/src/geometry.d.ts
|
|
2120
|
-
|
|
2121
|
-
```ts
|
|
2122
|
-
import type { Geometry, Position } from '../data/types.js';
|
|
2123
|
-
export type Project = (position: Position) => [number, number];
|
|
2124
|
-
export declare function rawProject([longitude, latitude]: Position): [number, number];
|
|
2125
|
-
export declare function positions(geometry: Geometry | null | undefined): Position[];
|
|
2126
|
-
export declare function geometryPath(geometry: Geometry | null | undefined, project: Project): string;
|
|
2127
|
-
```
|
|
2128
|
-
|
|
2129
2209
|
### ./dist/src/guide.d.ts
|
|
2130
2210
|
|
|
2131
2211
|
```ts
|
package/llms.txt
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Offline San Francisco SVG maps with explicit geographic imports, no runtime dependencies, static rendering, and a browser controller.
|
|
4
4
|
|
|
5
|
-
@kahwee/sf-map-svg 4.
|
|
5
|
+
@kahwee/sf-map-svg 4.4.0 · local working tree. Node >=24.0.0 for server rendering; browsers need a DOM and a JSON-capable bundler.
|
|
6
6
|
|
|
7
7
|
These are working-tree docs. Unreleased changelog entries may describe APIs absent from npm; verify the installed version before copying them.
|
|
8
8
|
|
package/package.json
CHANGED