feat(sources): add multiple sources
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user