feat(proj): init
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { fieldsForLayer } from "./layerFields";
|
||||
import {
|
||||
asColor,
|
||||
asNumber,
|
||||
isExpression,
|
||||
LAYER_TYPE_LABEL,
|
||||
toColorInput,
|
||||
type StyleLayer
|
||||
} from "./styleModel";
|
||||
|
||||
type Props = {
|
||||
layer: StyleLayer | null;
|
||||
readOnly: boolean;
|
||||
onPaint: (property: string, value: unknown) => void;
|
||||
onLayout: (property: string, value: unknown) => void;
|
||||
};
|
||||
|
||||
export function LayerInspector({ layer, readOnly, onPaint, onLayout }: Props) {
|
||||
if (!layer) {
|
||||
return (
|
||||
<aside className="style-pane style-pane--props">
|
||||
<header className="style-pane__head">
|
||||
<p className="style-pane__kicker">Свойства</p>
|
||||
<strong>Слой не выбран</strong>
|
||||
</header>
|
||||
<p className="style-pane__hint">Выберите слой справа или кликните объект на карте.</p>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const fields = fieldsForLayer(layer.type);
|
||||
const hidden = layer.layout?.visibility === "none";
|
||||
|
||||
return (
|
||||
<aside className="style-pane style-pane--props">
|
||||
<header className="style-pane__head">
|
||||
<p className="style-pane__kicker">{LAYER_TYPE_LABEL[layer.type] ?? layer.type}</p>
|
||||
<strong>{layer.id}</strong>
|
||||
{layer["source-layer"] ? <span className="style-pane__meta">{layer["source-layer"]}</span> : null}
|
||||
</header>
|
||||
|
||||
<div className="prop-list">
|
||||
{fields.map((field) => {
|
||||
if (field.kind === "visibility") {
|
||||
return (
|
||||
<label key="visibility" className="prop">
|
||||
<span>Видимость</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`prop__toggle${hidden ? "" : " is-on"}`}
|
||||
disabled={readOnly}
|
||||
onClick={() => onLayout("visibility", hidden ? "visible" : "none")}
|
||||
>
|
||||
{hidden ? "скрыт" : "виден"}
|
||||
</button>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const raw = layer.paint?.[field.property];
|
||||
const complex = isExpression(raw) && asColor(raw) === null && asNumber(raw) === null;
|
||||
|
||||
if (field.kind === "color") {
|
||||
const color = asColor(raw);
|
||||
return (
|
||||
<label key={field.property} className="prop">
|
||||
<span>{field.label}</span>
|
||||
{complex ? (
|
||||
<div className="prop__complex">
|
||||
<em>формула</em>
|
||||
<button type="button" disabled={readOnly} onClick={() => onPaint(field.property, "#888888")}>
|
||||
заменить цветом
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prop__color">
|
||||
<input
|
||||
type="color"
|
||||
disabled={readOnly}
|
||||
value={toColorInput(color ?? "#888888")}
|
||||
onChange={(e) => onPaint(field.property, e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
disabled={readOnly}
|
||||
value={color ?? ""}
|
||||
placeholder="#000000"
|
||||
onChange={(e) => onPaint(field.property, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const number = asNumber(raw);
|
||||
return (
|
||||
<label key={field.property} className="prop">
|
||||
<span>{field.label}</span>
|
||||
{complex || number === null ? (
|
||||
<div className="prop__complex">
|
||||
<em>{complex ? "по зуму" : "не задано"}</em>
|
||||
<input
|
||||
type="number"
|
||||
disabled={readOnly}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
placeholder={String(field.max > 1 ? 2 : 1)}
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value);
|
||||
if (Number.isFinite(next)) {
|
||||
onPaint(field.property, next);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="prop__range">
|
||||
<input
|
||||
type="range"
|
||||
disabled={readOnly}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
value={number}
|
||||
onChange={(e) => onPaint(field.property, Number(e.target.value))}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
disabled={readOnly}
|
||||
min={field.min}
|
||||
max={field.max}
|
||||
step={field.step}
|
||||
value={number}
|
||||
onChange={(e) => onPaint(field.property, Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { LAYER_TYPE_LABEL, type StyleLayer } from "./styleModel";
|
||||
|
||||
type Props = {
|
||||
layers: StyleLayer[];
|
||||
selectedId: string | null;
|
||||
readOnly: boolean;
|
||||
onSelect: (id: string) => void;
|
||||
onToggle: (id: string, visible: boolean) => void;
|
||||
};
|
||||
|
||||
export function LayerList({ layers, selectedId, readOnly, onSelect, onToggle }: Props) {
|
||||
const [query, setQuery] = useState("");
|
||||
const visible = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const items = [...layers].reverse();
|
||||
if (!q) {
|
||||
return items;
|
||||
}
|
||||
return items.filter(
|
||||
(layer) =>
|
||||
layer.id.toLowerCase().includes(q) ||
|
||||
layer.type.toLowerCase().includes(q) ||
|
||||
(layer["source-layer"] ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}, [layers, query]);
|
||||
|
||||
return (
|
||||
<aside className="style-pane style-pane--layers">
|
||||
<header className="style-pane__head">
|
||||
<div>
|
||||
<p className="style-pane__kicker">Слои</p>
|
||||
<strong>{layers.length}</strong>
|
||||
</div>
|
||||
<input
|
||||
className="style-pane__search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Поиск"
|
||||
aria-label="Поиск слоя"
|
||||
/>
|
||||
</header>
|
||||
<ul className="layer-list">
|
||||
{visible.map((layer) => {
|
||||
const hidden = layer.layout?.visibility === "none";
|
||||
return (
|
||||
<li key={layer.id}>
|
||||
<button
|
||||
type="button"
|
||||
className={`layer-row${selectedId === layer.id ? " is-active" : ""}${hidden ? " is-hidden" : ""}`}
|
||||
onClick={() => onSelect(layer.id)}
|
||||
>
|
||||
<span className={`layer-row__type layer-row__type--${layer.type}`}>{LAYER_TYPE_LABEL[layer.type] ?? layer.type}</span>
|
||||
<span className="layer-row__id">{layer.id}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="layer-row__eye"
|
||||
disabled={readOnly}
|
||||
title={hidden ? "Показать" : "Скрыть"}
|
||||
onClick={() => onToggle(layer.id, hidden)}
|
||||
>
|
||||
{hidden ? "○" : "●"}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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";
|
||||
|
||||
type Props = {
|
||||
style: MapStyle;
|
||||
tilesUrl: string;
|
||||
selectedLayerId: string | null;
|
||||
lastEdit: StyleEdit | null;
|
||||
styleEpoch: number;
|
||||
onSelectLayer: (layerId: string) => void;
|
||||
};
|
||||
|
||||
export function LiveMap({ style, tilesUrl, selectedLayerId, lastEdit, styleEpoch, onSelectLayer }: Props) {
|
||||
const el = useRef<HTMLDivElement | null>(null);
|
||||
const mapRef = useRef<MapLibreMap | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!el.current || !tilesUrl) {
|
||||
return;
|
||||
}
|
||||
const map = new maplibregl.Map({
|
||||
container: el.current,
|
||||
style: bindStyleForPreview(style, tilesUrl) as StyleSpecification,
|
||||
center: [37.6173, 55.7558],
|
||||
zoom: 11,
|
||||
maxZoom: 18,
|
||||
attributionControl: { compact: true, customAttribution: "© OpenStreetMap" },
|
||||
transformRequest: (url) => ({ url: new URL(url, window.location.origin).href })
|
||||
});
|
||||
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
|
||||
map.on("click", (event) => {
|
||||
const hit = map.queryRenderedFeatures(event.point)[0];
|
||||
if (hit?.layer?.id) {
|
||||
onSelectLayer(hit.layer.id);
|
||||
}
|
||||
});
|
||||
mapRef.current = map;
|
||||
return () => {
|
||||
map.remove();
|
||||
mapRef.current = null;
|
||||
};
|
||||
// Recreate only when tiles source changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tilesUrl, styleEpoch]);
|
||||
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !lastEdit || !map.isStyleLoaded() || !map.getLayer(lastEdit.layerId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (lastEdit.target === "paint") {
|
||||
map.setPaintProperty(lastEdit.layerId, lastEdit.property, lastEdit.value);
|
||||
} else {
|
||||
map.setLayoutProperty(lastEdit.layerId, lastEdit.property, lastEdit.value);
|
||||
}
|
||||
} catch {
|
||||
map.setStyle(bindStyleForPreview(style, tilesUrl) as StyleSpecification, { diff: true });
|
||||
}
|
||||
}, [lastEdit, style, tilesUrl]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="live-map"
|
||||
ref={el}
|
||||
role="application"
|
||||
aria-label="Превью стиля"
|
||||
data-selected-layer={selectedLayerId ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import { api, getPreviewToken, type SourceItem } from "../../api";
|
||||
import { Button, Flash } from "../../components/ui";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { LayerInspector } from "./LayerInspector";
|
||||
import { LayerList } from "./LayerList";
|
||||
import { LiveMap } from "./LiveMap";
|
||||
import {
|
||||
isMapStyle,
|
||||
tilesTemplate,
|
||||
updateLayer,
|
||||
type MapStyle,
|
||||
type StyleEdit,
|
||||
type StyleLayer
|
||||
} from "./styleModel";
|
||||
import "./editor.css";
|
||||
|
||||
export function StyleEditorPage() {
|
||||
const { name } = useParams<{ name: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const [style, setStyle] = useState<MapStyle | null>(null);
|
||||
const [sources, setSources] = useState<SourceItem[]>([]);
|
||||
const [source, setSource] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [lastEdit, setLastEdit] = useState<StyleEdit | null>(null);
|
||||
const [styleEpoch, setStyleEpoch] = useState(0);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [isPreset, setIsPreset] = useState(true);
|
||||
const token = getPreviewToken();
|
||||
|
||||
useEffect(() => {
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
void Promise.all([api.getStyle(name), api.styles(), api.sources()])
|
||||
.then(([raw, list, sourceList]) => {
|
||||
if (!isMapStyle(raw)) {
|
||||
throw new Error("Стиль должен быть объектом MapLibre (version 8).");
|
||||
}
|
||||
setStyle(raw);
|
||||
setIsPreset(list.find((item) => item.name === name)?.isPreset ?? true);
|
||||
const enabled = sourceList.filter((item) => item.enabled);
|
||||
setSources(enabled);
|
||||
setSource((current) => current || enabled[0]?.id || "");
|
||||
const first = raw.layers?.[raw.layers.length - 1]?.id ?? null;
|
||||
setSelectedId(first);
|
||||
setStyleEpoch((n) => n + 1);
|
||||
})
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось открыть стиль"));
|
||||
}, [name]);
|
||||
|
||||
const layers = style?.layers ?? [];
|
||||
const selected: StyleLayer | null = layers.find((layer) => layer.id === selectedId) ?? null;
|
||||
const tilesUrl = useMemo(() => {
|
||||
if (!user || !source || !token) {
|
||||
return "";
|
||||
}
|
||||
return tilesTemplate(user.slug, source, token);
|
||||
}, [user, source, token]);
|
||||
|
||||
const apply = (edit: StyleEdit) => {
|
||||
if (!style || isPreset) {
|
||||
return;
|
||||
}
|
||||
setStyle(updateLayer(style, edit));
|
||||
setLastEdit(edit);
|
||||
};
|
||||
|
||||
if (!name || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="style-workspace">
|
||||
<header className="style-workspace__bar">
|
||||
<div className="style-workspace__title">
|
||||
<Link to="/styles" className="style-workspace__back">
|
||||
Стили
|
||||
</Link>
|
||||
<h1>{name}</h1>
|
||||
<span className={`style-workspace__badge${isPreset ? " is-preset" : ""}`}>
|
||||
{isPreset ? "пресет" : "мой стиль"}
|
||||
</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}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{!isPreset ? (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={saving || !style}
|
||||
onClick={() => {
|
||||
if (!style) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
void api
|
||||
.saveStyle(name, style)
|
||||
.then(() => setStyleEpoch((n) => n + 1))
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось сохранить"))
|
||||
.finally(() => setSaving(false));
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (!window.confirm(`Удалить стиль ${name}?`)) {
|
||||
return;
|
||||
}
|
||||
void api
|
||||
.deleteStyle(name)
|
||||
.then(() => navigate("/styles"))
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось удалить"));
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button variant="primary" size="sm" onClick={() => navigate("/styles")}>
|
||||
Клонировать в списке
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div className="style-workspace__flash">
|
||||
<Flash error={error} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isPreset ? (
|
||||
<p className="style-workspace__note">Пресет только для просмотра. Склонируйте его в «Стили», чтобы править цвета и толщины.</p>
|
||||
) : null}
|
||||
|
||||
<div className="style-workspace__body">
|
||||
<LayerInspector
|
||||
layer={selected}
|
||||
readOnly={isPreset}
|
||||
onPaint={(property, value) => selected && apply({ layerId: selected.id, target: "paint", property, value })}
|
||||
onLayout={(property, value) => selected && apply({ layerId: selected.id, target: "layout", property, value })}
|
||||
/>
|
||||
<section className="style-workspace__map">
|
||||
{style && tilesUrl ? (
|
||||
<LiveMap
|
||||
style={style}
|
||||
tilesUrl={tilesUrl}
|
||||
selectedLayerId={selectedId}
|
||||
lastEdit={lastEdit}
|
||||
styleEpoch={styleEpoch}
|
||||
onSelectLayer={setSelectedId}
|
||||
/>
|
||||
) : (
|
||||
<div className="style-workspace__empty">
|
||||
{token
|
||||
? "Нет источника тайлов."
|
||||
: "Создайте токен в разделе «Токены» — без него превью не загрузит персональные тайлы."}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<LayerList
|
||||
layers={layers}
|
||||
selectedId={selectedId}
|
||||
readOnly={isPreset}
|
||||
onSelect={setSelectedId}
|
||||
onToggle={(id, visible) => apply({ layerId: id, target: "layout", property: "visibility", value: visible ? "visible" : "none" })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import maplibregl from "maplibre-gl";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
|
||||
type Props = {
|
||||
slug: string;
|
||||
name: string;
|
||||
source: string;
|
||||
token: string;
|
||||
nonce: number;
|
||||
};
|
||||
|
||||
export function StylePreview({ slug, name, source, token, nonce }: Props) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || !source) {
|
||||
return;
|
||||
}
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: ref.current,
|
||||
style: `/u/${slug}/styles/${name}?source=${encodeURIComponent(source)}&token=${encodeURIComponent(token)}&n=${nonce}`,
|
||||
center: [37.6173, 55.7558],
|
||||
zoom: 10
|
||||
});
|
||||
|
||||
return () => map.remove();
|
||||
}, [slug, name, source, token, nonce]);
|
||||
|
||||
return <div className="map-frame" ref={ref} />;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { api, type StyleItem } from "../../api";
|
||||
import { Button, Flash, PageHeader, Section } from "../../components/ui";
|
||||
import { AppSelect, AppTextField } from "../../ui";
|
||||
|
||||
export function StylesPage() {
|
||||
const navigate = useNavigate();
|
||||
const [items, setItems] = useState<StyleItem[]>([]);
|
||||
const [name, setName] = useState("");
|
||||
const [from, setFrom] = useState("osm-bright");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const reload = () =>
|
||||
api
|
||||
.styles()
|
||||
.then(setItems)
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось загрузить стили"));
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, []);
|
||||
|
||||
const presets = items.filter((item) => item.isPreset);
|
||||
const cloneFrom = presets.length > 0 ? from : "osm-bright";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
eyebrow="Стили"
|
||||
title="Карта"
|
||||
description="Клонируйте пресет и откройте визуальный редактор: слои, карта и свойства."
|
||||
/>
|
||||
<Section title="Клонировать пресет">
|
||||
<div className="form-row">
|
||||
<AppTextField id="style-name" label="Имя копии" value={name} onChange={(e) => setName(e.target.value)} placeholder="my-bright" />
|
||||
<AppSelect
|
||||
id="style-from"
|
||||
label="Пресет"
|
||||
value={cloneFrom}
|
||||
onChange={(e) => setFrom(e.target.value)}
|
||||
>
|
||||
{(presets.length > 0 ? presets : [{ name: "osm-bright" }]).map((item) => (
|
||||
<option key={item.name} value={item.name}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</AppSelect>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
void api
|
||||
.cloneStyle(name, cloneFrom)
|
||||
.then((created) => navigate(`/styles/${created.name}`))
|
||||
.catch((err: unknown) => setError(err instanceof Error ? err.message : "Не удалось клонировать"));
|
||||
}}
|
||||
>
|
||||
Клонировать
|
||||
</Button>
|
||||
</div>
|
||||
<Flash error={error} />
|
||||
</Section>
|
||||
<Section title="Список">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Имя</th>
|
||||
<th>Источник</th>
|
||||
<th>Тип</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.name}>
|
||||
<td>
|
||||
<Link to={`/styles/${item.name}`}>{item.name}</Link>
|
||||
</td>
|
||||
<td>{item.createdFrom ?? "—"}</td>
|
||||
<td>{item.isPreset ? "пресет" : "мой"}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
.app-shell--workspace .app-shell__main {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.style-workspace {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr;
|
||||
height: calc(100vh - 44px);
|
||||
min-height: 0;
|
||||
background: var(--grouped-background);
|
||||
}
|
||||
|
||||
.style-workspace__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
min-height: 52px;
|
||||
padding: 0 var(--gutter);
|
||||
border-bottom: var(--hairline) solid var(--separator);
|
||||
background: var(--grouped-surface);
|
||||
}
|
||||
|
||||
.style-workspace__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.style-workspace__title h1 {
|
||||
margin: 0;
|
||||
font-size: var(--text-title3);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.style-workspace__back {
|
||||
color: var(--accent);
|
||||
font-size: var(--text-footnote);
|
||||
}
|
||||
|
||||
.style-workspace__badge {
|
||||
font-size: var(--text-caption1);
|
||||
padding: 2px 8px;
|
||||
border-radius: var(--radius-capsule);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.style-workspace__badge.is-preset {
|
||||
background: var(--system-gray5);
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.style-workspace__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.style-workspace__source {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
font-size: var(--text-caption1);
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.style-workspace__source select {
|
||||
min-height: 34px;
|
||||
min-width: 14rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: var(--hairline) solid var(--separator);
|
||||
background: var(--grouped-background);
|
||||
color: var(--label);
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.style-workspace__flash,
|
||||
.style-workspace__note {
|
||||
margin: 0;
|
||||
padding: var(--space-2) var(--gutter);
|
||||
font-size: var(--text-footnote);
|
||||
color: var(--secondary-label);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.style-workspace__body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 300px) minmax(0, 1fr) minmax(240px, 300px);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.style-workspace__map {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.live-map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.style-workspace__empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
padding: var(--gutter);
|
||||
color: var(--secondary-label);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.style-pane {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
min-height: 0;
|
||||
background: var(--grouped-surface);
|
||||
border-inline: var(--hairline) solid var(--separator);
|
||||
}
|
||||
|
||||
.style-pane__head {
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-bottom: var(--hairline) solid var(--separator);
|
||||
}
|
||||
|
||||
.style-pane__kicker {
|
||||
margin: 0 0 2px;
|
||||
font-size: var(--text-caption1);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.style-pane__head strong {
|
||||
display: block;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.style-pane__meta,
|
||||
.style-pane__hint {
|
||||
display: block;
|
||||
margin-top: var(--space-2);
|
||||
color: var(--secondary-label);
|
||||
font-size: var(--text-footnote);
|
||||
}
|
||||
|
||||
.style-pane__search {
|
||||
width: 100%;
|
||||
margin-top: var(--space-2);
|
||||
min-height: 34px;
|
||||
border: var(--hairline) solid var(--separator);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 10px;
|
||||
background: var(--grouped-background);
|
||||
color: var(--label);
|
||||
}
|
||||
|
||||
.layer-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--space-2);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.layer-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.layer-row {
|
||||
display: grid;
|
||||
grid-template-columns: 4.6rem 1fr;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 4px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.layer-row:hover,
|
||||
.layer-row.is-active {
|
||||
background: var(--system-gray5);
|
||||
}
|
||||
|
||||
.layer-row.is-hidden .layer-row__id {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.layer-row__type {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.layer-row__id {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--text-footnote);
|
||||
}
|
||||
|
||||
.layer-row__eye {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--secondary-label);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.prop-list {
|
||||
overflow: auto;
|
||||
padding: var(--space-3) var(--space-4) var(--space-5);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.prop {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: var(--text-footnote);
|
||||
color: var(--secondary-label);
|
||||
}
|
||||
|
||||
.prop__color,
|
||||
.prop__range,
|
||||
.prop__complex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.prop__color input[type="color"] {
|
||||
width: 40px;
|
||||
height: 34px;
|
||||
padding: 0;
|
||||
border: var(--hairline) solid var(--separator);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.prop input[type="text"],
|
||||
.prop input[type="number"] {
|
||||
width: 100%;
|
||||
min-height: 34px;
|
||||
border: var(--hairline) solid var(--separator);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 8px;
|
||||
background: var(--grouped-background);
|
||||
color: var(--label);
|
||||
}
|
||||
|
||||
.prop input[type="range"] {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.prop__toggle {
|
||||
min-height: 34px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-capsule);
|
||||
padding: 0 12px;
|
||||
background: var(--system-gray5);
|
||||
color: var(--label);
|
||||
}
|
||||
|
||||
.prop__toggle.is-on {
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.prop__complex em {
|
||||
font-style: normal;
|
||||
color: var(--system-orange);
|
||||
font-size: var(--text-caption1);
|
||||
}
|
||||
|
||||
.prop__complex button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.style-workspace {
|
||||
height: auto;
|
||||
min-height: calc(100vh - 44px);
|
||||
}
|
||||
|
||||
.style-workspace__body {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: 48vh auto auto;
|
||||
}
|
||||
|
||||
.style-workspace__map {
|
||||
min-height: 48vh;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export type LayerField =
|
||||
| { kind: "color"; property: string; label: string }
|
||||
| { kind: "number"; property: string; label: string; min: number; max: number; step: number }
|
||||
| { kind: "visibility" };
|
||||
|
||||
export function fieldsForLayer(type: string): LayerField[] {
|
||||
switch (type) {
|
||||
case "background":
|
||||
return [
|
||||
{ kind: "color", property: "background-color", label: "Цвет фона" },
|
||||
{ kind: "number", property: "background-opacity", label: "Прозрачность", min: 0, max: 1, step: 0.05 }
|
||||
];
|
||||
case "fill":
|
||||
return [
|
||||
{ kind: "color", property: "fill-color", label: "Заливка" },
|
||||
{ kind: "color", property: "fill-outline-color", label: "Обводка" },
|
||||
{ kind: "number", property: "fill-opacity", label: "Прозрачность", min: 0, max: 1, step: 0.05 },
|
||||
{ kind: "visibility" }
|
||||
];
|
||||
case "line":
|
||||
return [
|
||||
{ kind: "color", property: "line-color", label: "Цвет линии" },
|
||||
{ kind: "number", property: "line-width", label: "Толщина", min: 0, max: 24, step: 0.2 },
|
||||
{ kind: "number", property: "line-opacity", label: "Прозрачность", min: 0, max: 1, step: 0.05 },
|
||||
{ kind: "visibility" }
|
||||
];
|
||||
case "circle":
|
||||
return [
|
||||
{ kind: "color", property: "circle-color", label: "Цвет" },
|
||||
{ kind: "number", property: "circle-radius", label: "Радиус", min: 0, max: 24, step: 0.2 },
|
||||
{ kind: "number", property: "circle-opacity", label: "Прозрачность", min: 0, max: 1, step: 0.05 },
|
||||
{ kind: "color", property: "circle-stroke-color", label: "Обводка" },
|
||||
{ kind: "number", property: "circle-stroke-width", label: "Толщина обводки", min: 0, max: 8, step: 0.2 },
|
||||
{ kind: "visibility" }
|
||||
];
|
||||
case "symbol":
|
||||
return [
|
||||
{ kind: "color", property: "text-color", label: "Цвет текста" },
|
||||
{ kind: "color", property: "text-halo-color", label: "Обводка текста" },
|
||||
{ kind: "number", property: "text-halo-width", label: "Толщина обводки", min: 0, max: 8, step: 0.1 },
|
||||
{ kind: "number", property: "text-size", label: "Размер текста", min: 8, max: 32, step: 1 },
|
||||
{ kind: "visibility" }
|
||||
];
|
||||
case "fill-extrusion":
|
||||
return [
|
||||
{ kind: "color", property: "fill-extrusion-color", label: "Цвет" },
|
||||
{ kind: "number", property: "fill-extrusion-opacity", label: "Прозрачность", min: 0, max: 1, step: 0.05 },
|
||||
{ kind: "number", property: "fill-extrusion-height", label: "Высота", min: 0, max: 200, step: 1 },
|
||||
{ kind: "visibility" }
|
||||
];
|
||||
default:
|
||||
return [{ kind: "visibility" }];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
export type StyleLayer = {
|
||||
id: string;
|
||||
type: string;
|
||||
source?: string;
|
||||
"source-layer"?: string;
|
||||
minzoom?: number;
|
||||
maxzoom?: number;
|
||||
filter?: unknown;
|
||||
layout?: Record<string, unknown>;
|
||||
paint?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MapStyle = {
|
||||
version: number;
|
||||
name?: string;
|
||||
sources?: Record<string, unknown>;
|
||||
glyphs?: string;
|
||||
sprite?: string;
|
||||
layers?: StyleLayer[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type StyleEdit = {
|
||||
layerId: string;
|
||||
target: "paint" | "layout";
|
||||
property: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
export function isMapStyle(value: unknown): value is MapStyle {
|
||||
return Boolean(value && typeof value === "object" && "version" in value);
|
||||
}
|
||||
|
||||
export function tilesTemplate(slug: string, source: string, token: string): string {
|
||||
return `/u/${encodeURIComponent(slug)}/tiles/${encodeURIComponent(source)}/{z}/{x}/{y}.pbf?v=3&token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export function bindStyleForPreview(style: MapStyle, tilesUrl: string): MapStyle {
|
||||
const next = structuredClone(style);
|
||||
const sources = { ...(next.sources ?? {}) };
|
||||
for (const key of Object.keys(sources)) {
|
||||
const source = sources[key];
|
||||
if (!source || typeof source !== "object") {
|
||||
continue;
|
||||
}
|
||||
const vector = source as Record<string, unknown>;
|
||||
if (vector.type !== "vector") {
|
||||
continue;
|
||||
}
|
||||
vector.tiles = [tilesUrl];
|
||||
vector.scheme = "xyz";
|
||||
delete vector.url;
|
||||
delete vector.bounds;
|
||||
sources[key] = vector;
|
||||
}
|
||||
next.sources = sources;
|
||||
if (!next.glyphs) {
|
||||
next.glyphs = "https://demotiles.maplibre.org/font/{fontstack}/{range}.pbf";
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
export function updateLayer(style: MapStyle, edit: StyleEdit): MapStyle {
|
||||
const next = structuredClone(style);
|
||||
const layer = next.layers?.find((item) => item.id === edit.layerId);
|
||||
if (!layer) {
|
||||
return style;
|
||||
}
|
||||
const bag = { ...(layer[edit.target] ?? {}) };
|
||||
if (edit.value === undefined) {
|
||||
delete bag[edit.property];
|
||||
} else {
|
||||
bag[edit.property] = edit.value;
|
||||
}
|
||||
layer[edit.target] = bag;
|
||||
return next;
|
||||
}
|
||||
|
||||
export function asColor(value: unknown): string | null {
|
||||
return typeof value === "string" && (/^#([0-9a-f]{3,8})$/i.test(value) || /^(rgb|hsl)a?\(/i.test(value))
|
||||
? value
|
||||
: null;
|
||||
}
|
||||
|
||||
export function asNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function isExpression(value: unknown): boolean {
|
||||
return Array.isArray(value) || (Boolean(value) && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
export function toColorInput(value: string): string {
|
||||
if (/^#[0-9a-f]{6}$/i.test(value)) {
|
||||
return value;
|
||||
}
|
||||
if (/^#[0-9a-f]{3}$/i.test(value)) {
|
||||
const [, a, b, c] = value;
|
||||
return `#${a}${a}${b}${b}${c}${c}`;
|
||||
}
|
||||
return "#888888";
|
||||
}
|
||||
|
||||
export const LAYER_TYPE_LABEL: Record<string, string> = {
|
||||
background: "фон",
|
||||
fill: "заливка",
|
||||
line: "линия",
|
||||
symbol: "подпись",
|
||||
circle: "точка",
|
||||
"fill-extrusion": "объём",
|
||||
heatmap: "тепло",
|
||||
hillshade: "рельеф",
|
||||
raster: "растр"
|
||||
};
|
||||
Reference in New Issue
Block a user