Files
mdm-ordinis/ordinis-admin-ui/src/components/geo/AoiPickerDialog.tsx
T
Zimin A.N. b94912789f fix(fonts): semantic typography utilities per handoff (7 roles)
Заменяет 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.
2026-05-11 14:31:35 +03:00

365 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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],
],
],
}
}