b94912789f
Заменяет blanket override из MR !45 на типизированную type scale per design_handoff_ordinis_mdm/README.md "Scale" section. Семь semantic @utility в styles.css: text-title-xl 22/600 — modal title, section header text-title-lg 17/600 — page title в editor text-title-md 15/600 — dictionary card title text-body 13/400 — workhorse: body, buttons, tabs, inputs text-cell 12.5/500 — table cell text text-mono 11/500 — Mono: IDs, dates, FK refs (font-mono baked in) text-cap 10.5/600 — Tektur UPPERCASE caption (font/uppercase baked in) Audit phases: P1: добавил 7 утилит, font/uppercase/tracking baked где надо P2: 43 deterministic codemods (font-mono+text-2xs/[11px] → text-mono, [15/17/22]px+font-semibold → text-title-*, [12.5]px → text-cell, [10.5]px+uppercase+tracking → text-cap) P3: 64 text-sm → text-body (handoff workhorse), 84 text-2xs context-aware (TableCell → text-cell, bare → text-cell, плюс cleanup caps в backticks template literals который Phase 2 пропустил), 25 text-xs (6 → text-mono когда с font-mono, 19 → text-cell), 8 titles text-base/lg → text-title-* P4: убрал --text-2xs override (no users), оставил --text-sm: 13px scoped к @nstart/ui passthrough (см. comment в styles.css — убирается в Stage 3.9) Stats: text-body: 69 | text-cell: 99 | text-mono: 50 | text-cap: 42 text-title-xl: 5 | text-title-lg: 5 | text-title-md: 7 text-sm/text-2xs/text-xs в src/: 0 (только в styles.css комментариях) Поведение всех 277 typography usages теперь явно соответствует handoff — каждое место осознанно выбрано под роль, не плажирующий override.
365 lines
11 KiB
TypeScript
365 lines
11 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
||
import { useTranslation } from 'react-i18next'
|
||
import { Alert, Button, FormActions, Modal } from '@/ui'
|
||
import L from 'leaflet'
|
||
import 'leaflet/dist/leaflet.css'
|
||
|
||
/**
|
||
* Результат AOI picker'а:
|
||
* - bbox: `west,south,east,north` (CSV для ?bbox= query)
|
||
* - polygon: GeoJSON Polygon (для ?polygon= query)
|
||
*
|
||
* Match'ит API ordinis-rest-api: `BoundingBox.parse` для bbox CSV,
|
||
* `GeoJsonPolygon.parse` для polygon JSON. Server validates lon/lat
|
||
* range, anti-meridian, polygon ring closure.
|
||
*/
|
||
export type AoiResult =
|
||
| { kind: 'bbox'; bboxCsv: string; geojson: GeoJSON.Polygon }
|
||
| { kind: 'polygon'; bboxCsv: string; geojson: GeoJSON.Polygon }
|
||
|
||
type Props = {
|
||
isOpen: boolean
|
||
onClose: () => void
|
||
onApply: (result: AoiResult) => void
|
||
/** Optional initial geometry to render and edit. */
|
||
initial?: GeoJSON.Polygon | null
|
||
}
|
||
|
||
type DrawMode = 'idle' | 'rectangle' | 'polygon'
|
||
|
||
/**
|
||
* AOI picker на Leaflet с custom draw logic (rectangle + polygon).
|
||
*
|
||
* Используем свою draw-implementation вместо `leaflet-draw` 1.0.4 —
|
||
* последний release lib'ы был в 2017, плохо совместим с Leaflet 1.8+:
|
||
* `_onMouseDown` сразу триггерит `_onMouseUp` на rectangle drag, и
|
||
* прямоугольник схлопывается в точку. Custom logic — ~80 строк, полный
|
||
* контроль, без сломанной зависимости.
|
||
*
|
||
* Rectangle: mousedown → mousemove preview → mouseup finalize.
|
||
* Polygon: click добавляет вершину, dblclick закрывает (≥3 точки).
|
||
*/
|
||
export const AoiPickerDialog = ({ isOpen, onClose, onApply, initial }: Props) => {
|
||
const { t } = useTranslation()
|
||
const mapDivRef = useRef<HTMLDivElement | null>(null)
|
||
const mapRef = useRef<L.Map | null>(null)
|
||
const drawnRef = useRef<L.FeatureGroup | null>(null)
|
||
const [shape, setShape] = useState<L.Layer | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [drawMode, setDrawMode] = useState<DrawMode>('idle')
|
||
const drawModeRef = useRef<DrawMode>('idle')
|
||
drawModeRef.current = drawMode
|
||
|
||
// Init map после mount + isOpen=true. RAF poll до non-zero size.
|
||
useEffect(() => {
|
||
if (!isOpen) return
|
||
let cancelled = false
|
||
let ro: ResizeObserver | null = null
|
||
let rafId = 0
|
||
|
||
const tryInit = () => {
|
||
if (cancelled) return
|
||
if (mapRef.current) return
|
||
const div = mapDivRef.current
|
||
|
||
if (!div || div.clientWidth === 0 || div.clientHeight === 0) {
|
||
if (rafId < 100) {
|
||
rafId += 1
|
||
requestAnimationFrame(tryInit)
|
||
}
|
||
return
|
||
}
|
||
|
||
const map = L.map(div, { zoomControl: true, attributionControl: false })
|
||
.setView([60, 90], 3)
|
||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||
maxZoom: 18,
|
||
}).addTo(map)
|
||
|
||
const drawn = new L.FeatureGroup().addTo(map)
|
||
drawnRef.current = drawn
|
||
|
||
if (initial && initial.coordinates?.[0]?.length >= 4) {
|
||
const ring = initial.coordinates[0].map(([lng, lat]) => L.latLng(lat, lng))
|
||
const poly = L.polygon(ring, { color: '#1d4ed8', weight: 2 })
|
||
drawn.addLayer(poly)
|
||
setShape(poly)
|
||
map.fitBounds(poly.getBounds(), { padding: [20, 20] })
|
||
}
|
||
|
||
attachDrawHandlers(map, drawn, drawModeRef, (newShape) => {
|
||
setShape(newShape)
|
||
setError(null)
|
||
setDrawMode('idle')
|
||
})
|
||
|
||
mapRef.current = map
|
||
ro = new ResizeObserver(() => {
|
||
if (mapRef.current) mapRef.current.invalidateSize()
|
||
})
|
||
ro.observe(div)
|
||
}
|
||
|
||
tryInit()
|
||
return () => {
|
||
cancelled = true
|
||
if (ro) ro.disconnect()
|
||
}
|
||
}, [isOpen, initial])
|
||
|
||
// Cursor + map drag state в зависимости от drawMode.
|
||
useEffect(() => {
|
||
const map = mapRef.current
|
||
if (!map) return
|
||
const container = map.getContainer()
|
||
if (drawMode === 'idle') {
|
||
container.style.cursor = ''
|
||
map.dragging.enable()
|
||
} else {
|
||
container.style.cursor = 'crosshair'
|
||
// Disable map drag в rectangle mode чтобы mousedown+drag рисовал, не панил.
|
||
if (drawMode === 'rectangle') map.dragging.disable()
|
||
else map.dragging.enable()
|
||
}
|
||
}, [drawMode])
|
||
|
||
// Cleanup при close.
|
||
useEffect(() => {
|
||
if (isOpen) return
|
||
if (mapRef.current) {
|
||
mapRef.current.remove()
|
||
mapRef.current = null
|
||
drawnRef.current = null
|
||
setShape(null)
|
||
setError(null)
|
||
setDrawMode('idle')
|
||
}
|
||
}, [isOpen])
|
||
|
||
const handleClear = () => {
|
||
drawnRef.current?.clearLayers()
|
||
setShape(null)
|
||
setError(null)
|
||
}
|
||
|
||
const handleApply = () => {
|
||
if (!shape) {
|
||
setError(t('aoi.error.noShape'))
|
||
return
|
||
}
|
||
const result = layerToResult(shape)
|
||
if (!result) {
|
||
setError(t('aoi.error.invalid'))
|
||
return
|
||
}
|
||
onApply(result)
|
||
onClose()
|
||
}
|
||
|
||
const toolbarBtn = (mode: DrawMode, label: string) => {
|
||
const active = drawMode === mode
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={() => setDrawMode(active ? 'idle' : mode)}
|
||
className={[
|
||
'px-3 py-1.5 text-cell font-display rounded-sm border transition-colors',
|
||
active
|
||
? 'border-accent bg-accent text-white'
|
||
: 'border-line bg-white text-ink hover:border-carbon/40',
|
||
].join(' ')}
|
||
>
|
||
{label}
|
||
</button>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Modal
|
||
isOpen={isOpen}
|
||
onClose={onClose}
|
||
title={t('aoi.dialog.title')}
|
||
maxWidth="max-w-5xl"
|
||
panelClassName="max-h-[calc(100vh-2rem)] my-4 flex flex-col"
|
||
bodyClassName="flex-1 flex flex-col gap-3 overflow-hidden"
|
||
>
|
||
<p className="text-body text-ink-2">{t('aoi.dialog.help')}</p>
|
||
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
{toolbarBtn('rectangle', t('aoi.dialog.drawRectangle'))}
|
||
{toolbarBtn('polygon', t('aoi.dialog.drawPolygon'))}
|
||
<button
|
||
type="button"
|
||
onClick={handleClear}
|
||
disabled={!shape}
|
||
className="px-3 py-1.5 text-cell font-display rounded-sm border border-line bg-white text-ink hover:border-carbon/40 disabled:opacity-40 disabled:cursor-not-allowed"
|
||
>
|
||
{t('aoi.dialog.clear')}
|
||
</button>
|
||
{drawMode === 'polygon' && (
|
||
<span className="text-cell text-mute">
|
||
{t('aoi.dialog.polygonHint')}
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
ref={mapDivRef}
|
||
className="rounded-sm border border-line shrink-0"
|
||
style={{ display: 'block', width: '100%', height: 520, minHeight: 520 }}
|
||
aria-label={t('aoi.dialog.mapAriaLabel')}
|
||
/>
|
||
|
||
{error && (
|
||
<Alert variant="error" title={t('error.failed')}>
|
||
{error}
|
||
</Alert>
|
||
)}
|
||
|
||
<FormActions>
|
||
<Button type="button" variant="ghost" onClick={onClose}>
|
||
{t('form.cancel')}
|
||
</Button>
|
||
<Button type="button" variant="primary" onClick={handleApply} disabled={!shape}>
|
||
{t('aoi.dialog.apply')}
|
||
</Button>
|
||
</FormActions>
|
||
</Modal>
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Custom draw handlers: подписываемся на map events, рисуем в зависимости
|
||
* от текущего drawMode (читаемого через ref чтобы не пересоздавать
|
||
* listeners на каждое изменение state).
|
||
*/
|
||
const attachDrawHandlers = (
|
||
map: L.Map,
|
||
drawn: L.FeatureGroup,
|
||
modeRef: React.RefObject<DrawMode>,
|
||
onComplete: (layer: L.Layer) => void,
|
||
) => {
|
||
const STYLE = { color: '#1d4ed8', weight: 2 }
|
||
|
||
// Rectangle: mousedown→start, mousemove→preview, mouseup→finalize.
|
||
let rectStart: L.LatLng | null = null
|
||
let rectPreview: L.Rectangle | null = null
|
||
|
||
map.on('mousedown', (e) => {
|
||
if (modeRef.current !== 'rectangle') return
|
||
rectStart = (e as L.LeafletMouseEvent).latlng
|
||
})
|
||
|
||
map.on('mousemove', (e) => {
|
||
if (modeRef.current !== 'rectangle' || !rectStart) return
|
||
const cur = (e as L.LeafletMouseEvent).latlng
|
||
if (rectPreview) {
|
||
rectPreview.setBounds(L.latLngBounds(rectStart, cur))
|
||
} else {
|
||
rectPreview = L.rectangle(L.latLngBounds(rectStart, cur), STYLE).addTo(drawn)
|
||
}
|
||
})
|
||
|
||
map.on('mouseup', () => {
|
||
if (modeRef.current !== 'rectangle' || !rectStart) return
|
||
if (rectPreview) {
|
||
// Finalize: replace previous shape с новым rectangle.
|
||
const finalRect = rectPreview
|
||
drawn.clearLayers()
|
||
drawn.addLayer(finalRect)
|
||
onComplete(finalRect)
|
||
}
|
||
rectStart = null
|
||
rectPreview = null
|
||
})
|
||
|
||
// Polygon: click→add vertex, dblclick→close, mousemove→preview line.
|
||
let polyPoints: L.LatLng[] = []
|
||
let polyPreview: L.Polyline | null = null
|
||
let polyMouseLine: L.Polyline | null = null
|
||
|
||
map.on('click', (e) => {
|
||
if (modeRef.current !== 'polygon') return
|
||
const pt = (e as L.LeafletMouseEvent).latlng
|
||
polyPoints.push(pt)
|
||
if (polyPreview) {
|
||
polyPreview.setLatLngs(polyPoints)
|
||
} else {
|
||
polyPreview = L.polyline(polyPoints, STYLE).addTo(drawn)
|
||
}
|
||
})
|
||
|
||
map.on('mousemove', (e) => {
|
||
if (modeRef.current !== 'polygon' || polyPoints.length === 0) return
|
||
const cur = (e as L.LeafletMouseEvent).latlng
|
||
const last = polyPoints[polyPoints.length - 1]
|
||
if (polyMouseLine) {
|
||
polyMouseLine.setLatLngs([last, cur])
|
||
} else {
|
||
polyMouseLine = L.polyline([last, cur], { ...STYLE, dashArray: '4 4' }).addTo(drawn)
|
||
}
|
||
})
|
||
|
||
map.on('dblclick', () => {
|
||
if (modeRef.current !== 'polygon') return
|
||
if (polyPoints.length < 3) return
|
||
// Finalize: replace с финальным polygon.
|
||
const finalPoly = L.polygon(polyPoints, STYLE)
|
||
drawn.clearLayers()
|
||
drawn.addLayer(finalPoly)
|
||
onComplete(finalPoly)
|
||
polyPoints = []
|
||
polyPreview = null
|
||
polyMouseLine = null
|
||
})
|
||
|
||
// Disable Leaflet's default zoom-on-doubleclick — конфликтует с polygon close.
|
||
map.doubleClickZoom.disable()
|
||
}
|
||
|
||
/**
|
||
* Конвертирует Leaflet layer (rectangle / polygon) в bbox CSV +
|
||
* GeoJSON Polygon. Возвращает null если geometry invalid.
|
||
*/
|
||
const layerToResult = (layer: L.Layer): AoiResult | null => {
|
||
if (layer instanceof L.Rectangle) {
|
||
const b = layer.getBounds()
|
||
const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}`
|
||
return { kind: 'bbox', bboxCsv: bbox, geojson: rectangleToGeoJSON(b) }
|
||
}
|
||
if (layer instanceof L.Polygon) {
|
||
const latlngsRaw = layer.getLatLngs()
|
||
const ring = (Array.isArray(latlngsRaw[0]) ? latlngsRaw[0] : latlngsRaw) as L.LatLng[]
|
||
if (!ring || ring.length < 3) return null
|
||
const closed = [...ring, ring[0]]
|
||
const coords = closed.map((p) => [p.lng, p.lat] as [number, number])
|
||
const b = layer.getBounds()
|
||
const bbox = `${b.getWest()},${b.getSouth()},${b.getEast()},${b.getNorth()}`
|
||
return {
|
||
kind: 'polygon',
|
||
bboxCsv: bbox,
|
||
geojson: { type: 'Polygon', coordinates: [coords] },
|
||
}
|
||
}
|
||
return null
|
||
}
|
||
|
||
const rectangleToGeoJSON = (b: L.LatLngBounds): GeoJSON.Polygon => {
|
||
const w = b.getWest()
|
||
const s = b.getSouth()
|
||
const e = b.getEast()
|
||
const n = b.getNorth()
|
||
return {
|
||
type: 'Polygon',
|
||
coordinates: [
|
||
[
|
||
[w, s],
|
||
[e, s],
|
||
[e, n],
|
||
[w, n],
|
||
[w, s],
|
||
],
|
||
],
|
||
}
|
||
}
|