feat(sources): add multiple sources

This commit is contained in:
vl.arkhangelskii
2026-09-21 04:51:23 +03:00
parent 1c187ed56b
commit 946b77540f
18 changed files with 1494 additions and 110 deletions
+28 -15
View File
@@ -1,28 +1,35 @@
import { useEffect, useRef } from "react";
import maplibregl, { type Map as MapLibreMap, type StyleSpecification } from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
import { bindStyleForPreview, type MapStyle, type StyleEdit } from "./styleModel";
import {
bindStyleForPreview,
primaryLayerId,
type ExtractBinding,
type MapStyle,
type StyleEdit
} from "./styleModel";
type Props = {
style: MapStyle;
tilesUrl: string;
bindings: ExtractBinding[];
selectedLayerId: string | null;
lastEdit: StyleEdit | null;
styleEpoch: number;
onSelectLayer: (layerId: string) => void;
};
export function LiveMap({ style, tilesUrl, selectedLayerId, lastEdit, styleEpoch, onSelectLayer }: Props) {
export function LiveMap({ style, bindings, selectedLayerId, lastEdit, styleEpoch, onSelectLayer }: Props) {
const el = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<MapLibreMap | null>(null);
const extras = bindings.slice(1).map((item) => item.extractId);
useEffect(() => {
if (!el.current || !tilesUrl) {
if (!el.current || bindings.length === 0) {
return;
}
const map = new maplibregl.Map({
container: el.current,
style: bindStyleForPreview(style, tilesUrl) as StyleSpecification,
style: bindStyleForPreview(style, bindings) as StyleSpecification,
center: [37.6173, 55.7558],
zoom: 11,
maxZoom: 18,
@@ -33,7 +40,7 @@ export function LiveMap({ style, tilesUrl, selectedLayerId, lastEdit, styleEpoch
map.on("click", (event) => {
const hit = map.queryRenderedFeatures(event.point)[0];
if (hit?.layer?.id) {
onSelectLayer(hit.layer.id);
onSelectLayer(primaryLayerId(hit.layer.id, extras));
}
});
mapRef.current = map;
@@ -41,25 +48,31 @@ export function LiveMap({ style, tilesUrl, selectedLayerId, lastEdit, styleEpoch
map.remove();
mapRef.current = null;
};
// Recreate only when tiles source changes.
// Recreate when extract set changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tilesUrl, styleEpoch]);
}, [bindings.map((item) => item.extractId).join(","), styleEpoch]);
useEffect(() => {
const map = mapRef.current;
if (!map || !lastEdit || !map.isStyleLoaded() || !map.getLayer(lastEdit.layerId)) {
if (!map || !lastEdit || !map.isStyleLoaded()) {
return;
}
const ids = [lastEdit.layerId, ...extras.map((id) => `${lastEdit.layerId}__${id}`)];
try {
if (lastEdit.target === "paint") {
map.setPaintProperty(lastEdit.layerId, lastEdit.property, lastEdit.value);
} else {
map.setLayoutProperty(lastEdit.layerId, lastEdit.property, lastEdit.value);
for (const layerId of ids) {
if (!map.getLayer(layerId)) {
continue;
}
if (lastEdit.target === "paint") {
map.setPaintProperty(layerId, lastEdit.property, lastEdit.value);
} else {
map.setLayoutProperty(layerId, lastEdit.property, lastEdit.value);
}
}
} catch {
map.setStyle(bindStyleForPreview(style, tilesUrl) as StyleSpecification, { diff: true });
map.setStyle(bindStyleForPreview(style, bindings) as StyleSpecification, { diff: true });
}
}, [lastEdit, style, tilesUrl]);
}, [lastEdit, style, bindings, extras]);
return (
<div
+37 -24
View File
@@ -10,6 +10,7 @@ import {
isMapStyle,
tilesTemplate,
updateLayer,
type ExtractBinding,
type MapStyle,
type StyleEdit,
type StyleLayer
@@ -22,7 +23,7 @@ export function StyleEditorPage() {
const { user } = useAuth();
const [style, setStyle] = useState<MapStyle | null>(null);
const [sources, setSources] = useState<SourceItem[]>([]);
const [source, setSource] = useState("");
const [selectedSourceIds, setSelectedSourceIds] = useState<string[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [lastEdit, setLastEdit] = useState<StyleEdit | null>(null);
const [styleEpoch, setStyleEpoch] = useState(0);
@@ -44,7 +45,7 @@ export function StyleEditorPage() {
setIsPreset(list.find((item) => item.name === name)?.isPreset ?? true);
const enabled = sourceList.filter((item) => item.enabled);
setSources(enabled);
setSource((current) => current || enabled[0]?.id || "");
setSelectedSourceIds((current) => (current.length > 0 ? current : enabled[0] ? [enabled[0].id] : []));
const first = raw.layers?.[raw.layers.length - 1]?.id ?? null;
setSelectedId(first);
setStyleEpoch((n) => n + 1);
@@ -54,12 +55,15 @@ export function StyleEditorPage() {
const layers = style?.layers ?? [];
const selected: StyleLayer | null = layers.find((layer) => layer.id === selectedId) ?? null;
const tilesUrl = useMemo(() => {
if (!user || !source || !token) {
return "";
const bindings: ExtractBinding[] = useMemo(() => {
if (!user || !token || selectedSourceIds.length === 0) {
return [];
}
return tilesTemplate(user.slug, source, token);
}, [user, source, token]);
return selectedSourceIds.map((id) => ({
extractId: id,
tilesUrl: tilesTemplate(user.slug, id, token)
}));
}, [user, token, selectedSourceIds]);
const apply = (edit: StyleEdit) => {
if (!style || isPreset) {
@@ -86,22 +90,31 @@ export function StyleEditorPage() {
</span>
</div>
<div className="style-workspace__actions">
<label className="style-workspace__source">
<span>Источник</span>
<select
value={source}
onChange={(e) => {
setSource(e.target.value);
setStyleEpoch((n) => n + 1);
}}
>
{sources.map((item) => (
<option key={item.id} value={item.id}>
<fieldset className="style-workspace__sources">
<legend>Округа</legend>
{sources.map((item) => {
const checked = selectedSourceIds.includes(item.id);
return (
<label key={item.id}>
<input
type="checkbox"
checked={checked}
onChange={() => {
setSelectedSourceIds((current) => {
if (checked) {
const next = current.filter((id) => id !== item.id);
return next.length > 0 ? next : current;
}
return [...current, item.id];
});
setStyleEpoch((n) => n + 1);
}}
/>
{item.name}
</option>
))}
</select>
</label>
</label>
);
})}
</fieldset>
{!isPreset ? (
<>
<Button
@@ -165,10 +178,10 @@ export function StyleEditorPage() {
onLayout={(property, value) => selected && apply({ layerId: selected.id, target: "layout", property, value })}
/>
<section className="style-workspace__map">
{style && tilesUrl ? (
{style && bindings.length > 0 ? (
<LiveMap
style={style}
tilesUrl={tilesUrl}
bindings={bindings}
selectedLayerId={selectedId}
lastEdit={lastEdit}
styleEpoch={styleEpoch}
+25
View File
@@ -67,6 +67,31 @@
color: var(--secondary-label);
}
.style-workspace__sources {
margin: 0;
padding: 0;
border: 0;
display: flex;
flex-wrap: wrap;
gap: 6px 12px;
max-width: 28rem;
font-size: var(--text-caption1);
color: var(--secondary-label);
}
.style-workspace__sources legend {
padding: 0;
margin-bottom: 4px;
}
.style-workspace__sources label {
display: flex;
align-items: center;
gap: 6px;
color: var(--label);
font-size: var(--text-footnote);
}
.style-workspace__source select {
min-height: 34px;
min-width: 14rem;
+61 -5
View File
@@ -35,9 +35,35 @@ export function tilesTemplate(slug: string, source: string, token: string): stri
return `/u/${encodeURIComponent(slug)}/tiles/${encodeURIComponent(source)}/{z}/{x}/{y}.pbf?v=3&token=${encodeURIComponent(token)}`;
}
export function bindStyleForPreview(style: MapStyle, tilesUrl: string): MapStyle {
export type ExtractBinding = {
extractId: string;
tilesUrl: string;
};
function applyVectorSource(source: Record<string, unknown>, tilesUrl: string): void {
source.type = "vector";
source.tiles = [tilesUrl];
source.scheme = "xyz";
delete source.url;
delete source.bounds;
}
function vectorKeys(style: MapStyle): string[] {
const sources = style.sources ?? {};
return Object.keys(sources).filter((key) => {
const source = sources[key];
return Boolean(source && typeof source === "object" && (source as { type?: string }).type === "vector");
});
}
export function bindStyleForPreview(style: MapStyle, bindings: ExtractBinding[]): MapStyle {
if (bindings.length === 0) {
return structuredClone(style);
}
const next = structuredClone(style);
const sources = { ...(next.sources ?? {}) };
const first = bindings[0];
for (const key of Object.keys(sources)) {
const source = sources[key];
if (!source || typeof source !== "object") {
@@ -47,19 +73,49 @@ export function bindStyleForPreview(style: MapStyle, tilesUrl: string): MapStyle
if (vector.type !== "vector") {
continue;
}
vector.tiles = [tilesUrl];
vector.scheme = "xyz";
delete vector.url;
delete vector.bounds;
applyVectorSource(vector, first.tilesUrl);
sources[key] = vector;
}
next.sources = sources;
if (!next.glyphs) {
next.glyphs = "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf";
}
const primary = vectorKeys(next);
const layers = [...(next.layers ?? [])];
for (const extra of bindings.slice(1)) {
const suffix = `__${extra.extractId}`;
for (const key of primary) {
const clone: Record<string, unknown> = { type: "vector" };
applyVectorSource(clone, extra.tilesUrl);
sources[key + suffix] = clone;
}
for (const layer of next.layers ?? []) {
if (!layer.source || !primary.includes(layer.source)) {
continue;
}
layers.push({
...structuredClone(layer),
id: `${layer.id}${suffix}`,
source: `${layer.source}${suffix}`
});
}
}
next.sources = sources;
next.layers = layers;
return next;
}
export function primaryLayerId(layerId: string, extraExtractIds: string[]): string {
for (const extractId of extraExtractIds) {
const suffix = `__${extractId}`;
if (layerId.endsWith(suffix)) {
return layerId.slice(0, -suffix.length);
}
}
return layerId;
}
export function updateLayer(style: MapStyle, edit: StyleEdit): MapStyle {
const next = structuredClone(style);
const layer = next.layers?.find((item) => item.id === edit.layerId);