diff --git a/src/pages/Route/RouteCreatePage/index.tsx b/src/pages/Route/RouteCreatePage/index.tsx index f8317cc..1d200bc 100644 --- a/src/pages/Route/RouteCreatePage/index.tsx +++ b/src/pages/Route/RouteCreatePage/index.tsx @@ -420,7 +420,15 @@ export const RouteCreatePage = observer(() => { type="number" value={scaleMin} onChange={(e) => { - const value = e.target.value; + let value = e.target.value; + if (Number(value) > 297) { + value = "297"; + } + + if (Number(value) < 10) { + value = "10"; + } + setScaleMin(value); if (value && scaleMax && Number(value) > Number(scaleMax)) { setScaleMax(value); @@ -447,6 +455,10 @@ export const RouteCreatePage = observer(() => { value={scaleMax} required onChange={(e) => { + if (Number(e.target.value) > 300) { + e.target.value = "300"; + } + const value = e.target.value; setScaleMax(value); }} diff --git a/src/pages/Route/RouteEditPage/index.tsx b/src/pages/Route/RouteEditPage/index.tsx index 77866f3..5a03801 100644 --- a/src/pages/Route/RouteEditPage/index.tsx +++ b/src/pages/Route/RouteEditPage/index.tsx @@ -393,20 +393,29 @@ export const RouteEditPage = observer(() => { type="number" value={editRouteData.scale_min ?? ""} onChange={(e) => { - const value = - e.target.value === "" ? null : parseFloat(e.target.value); + let value = e.target.value === "" ? null : e.target.value; + + if (value && Number(value) > 297) { + value = "297"; + } + + if (value && Number(value) < 10) { + value = "10"; + } + routeStore.setEditRouteData({ - scale_min: value, + scale_min: value ? Number(value) : null, }); // Если максимальный масштаб стал меньше минимального, обновляем его if ( value !== null && editRouteData.scale_max !== null && editRouteData.scale_max !== undefined && - value > editRouteData.scale_max + value && + Number(value) > (editRouteData.scale_max ?? 0) ) { routeStore.setEditRouteData({ - scale_max: value, + scale_max: value ? Number(value) : null, }); } }} @@ -418,12 +427,17 @@ export const RouteEditPage = observer(() => { label="Масштаб (макс)" type="number" value={editRouteData.scale_max ?? ""} - onChange={(e) => + onChange={(e) => { + let value = e.target.value; + + if (Number(value) > 300) { + value = "300"; + } + routeStore.setEditRouteData({ - scale_max: - e.target.value === "" ? null : parseFloat(e.target.value), - }) - } + scale_max: value === "" ? null : parseFloat(value), + }); + }} error={ editRouteData.scale_min !== null && editRouteData.scale_min !== undefined && diff --git a/src/pages/Route/route-preview/RightSidebar.tsx b/src/pages/Route/route-preview/RightSidebar.tsx index 428e6bf..ceabec4 100644 --- a/src/pages/Route/route-preview/RightSidebar.tsx +++ b/src/pages/Route/route-preview/RightSidebar.tsx @@ -145,8 +145,8 @@ export function RightSidebar() { onChange={(e) => { let newMinScale = Number(e.target.value); - if (newMinScale < 1) { - newMinScale = 1; + if (newMinScale < 10) { + newMinScale = 10; } setMinScale(newMinScale); @@ -189,8 +189,8 @@ export function RightSidebar() { onChange={(e) => { let newMaxScale = Number(e.target.value); - if (newMaxScale < 3) { - newMaxScale = 3; + if (newMaxScale < 13) { + newMaxScale = 13; } if (newMaxScale > 300) { diff --git a/src/pages/Route/route-preview/index.tsx b/src/pages/Route/route-preview/index.tsx index adfc1e8..1775545 100644 --- a/src/pages/Route/route-preview/index.tsx +++ b/src/pages/Route/route-preview/index.tsx @@ -1,6 +1,6 @@ import { useRef, useEffect, useState } from "react"; import { Widgets } from "./Widgets"; -import { Application, extend } from "@pixi/react"; +import { extend } from "@pixi/react"; import { Container, Graphics, @@ -12,22 +12,15 @@ import { import { Box, Stack } from "@mui/material"; import { MapDataProvider, useMapData } from "./MapDataContext"; import { TransformProvider, useTransform } from "./TransformContext"; -import { InfiniteCanvas } from "./InfiniteCanvas"; -import { TravelPath } from "./TravelPath"; import { LeftSidebar } from "./LeftSidebar"; import { RightSidebar } from "./RightSidebar"; import { coordinatesToLocal } from "./utils"; -import { LanguageSwitcher } from "@widgets"; -import { languageStore } from "@shared"; import { observer } from "mobx-react-lite"; -import { Sight } from "./Sight"; -import { SightData } from "./types"; -import { Station } from "./Station"; import { UP_SCALE } from "./Constants"; import { WebGLRouteMapPrototype } from "./webgl-prototype/WebGLRouteMapPrototype"; -import CircularProgress from "@mui/material/CircularProgress"; +import { CircularProgress } from "@mui/material"; extend({ Container, @@ -43,7 +36,7 @@ const Loading = () => { if (isRouteLoading || isStationLoading || isSightLoading) { return ( -
+
); @@ -91,15 +84,8 @@ export const RoutePreview = () => { }; export const RouteMap = observer(() => { - const { language } = languageStore; const { setPosition, setTransform, screenCenter } = useTransform(); - const { - routeData, - stationData, - sightData, - originalRouteData, - originalSightData, - } = useMapData(); + const { routeData, stationData, sightData, originalRouteData } = useMapData(); const [points, setPoints] = useState<{ x: number; y: number }[]>([]); const [isSetup, setIsSetup] = useState(false); diff --git a/src/pages/Route/route-preview/web-gl/LanguageSelector.tsx b/src/pages/Route/route-preview/web-gl/LanguageSelector.tsx index ef92193..2105738 100644 --- a/src/pages/Route/route-preview/web-gl/LanguageSelector.tsx +++ b/src/pages/Route/route-preview/web-gl/LanguageSelector.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState, type ReactElement } from "react"; +import { useEffect, useRef, useState, type ReactElement } from "react"; import { observer } from "mobx-react-lite"; import { languageStore } from "@shared"; @@ -114,7 +114,7 @@ const ArrowIcon = ({ rotation }: { rotation: number }) => ( const LanguageSelector = observer( ({ onBack, isSidebarOpen = true }: LanguageSelectorProps) => { - const { language, setLanguage } = languageStore; + const { setLanguage } = languageStore; const [isOpen, setIsOpen] = useState(false); const containerRef = useRef(null); @@ -145,20 +145,6 @@ const LanguageSelector = observer( const toggle = () => setIsOpen((prev) => !prev); - const containerWidth = useMemo(() => { - const BUTTON_SIZE = 56; - const GAP = 8; - const backWidth = onBack ? BUTTON_SIZE + GAP : 0; - const toggleWidth = BUTTON_SIZE; - const collapsedWidth = backWidth + toggleWidth + BUTTON_SIZE; - const expandedWidth = - backWidth + - toggleWidth + - LANGUAGES.length * BUTTON_SIZE + - (LANGUAGES.length - 1) * GAP; - return isOpen ? expandedWidth : collapsedWidth; - }, [isOpen, onBack]); - return (
{ - const canvasRef = useRef(null); - const glRef = useRef(null); - const programRef = useRef(null); - const bufferRef = useRef(null); - const pointProgramRef = useRef(null); - const pointBufferRef = useRef(null); - const screenLineProgramRef = useRef(null); - const screenLineBufferRef = useRef(null); - const attribsRef = useRef<{ a_pos: number } | null>(null); - const uniformsRef = useRef<{ - u_cameraPos: WebGLUniformLocation | null; - u_scale: WebGLUniformLocation | null; - u_resolution: WebGLUniformLocation | null; - u_color: WebGLUniformLocation | null; - } | null>(null); - - const { routeData, stationData, stationDataEn, stationDataZh, sightData } = - useMapData() as any; - const { - position, - scale, - setPosition, - setScale, - isAutoMode, - setIsAutoMode, - screenCenter, - setScreenCenter, - userActivityTimestamp, - updateUserActivity, - } = useTransform(); - - const cameraAnimationStore = useCameraAnimationStore(); - - // Ref для хранения ограничений масштаба - const scaleLimitsRef = useRef({ - min: null as number | null, - max: null as number | null, - }); - - // Обновляем ограничения масштаба при изменении routeData - useEffect(() => { - if ( - routeData?.scale_min !== undefined && - routeData?.scale_max !== undefined - ) { - scaleLimitsRef.current = { - min: routeData.scale_min / 10, - max: routeData.scale_max / 10, - }; - } - }, [routeData?.scale_min, routeData?.scale_max]); - - // Функция для ограничения масштаба значениями с бекенда - const clampScale = useCallback((value: number) => { - const { min, max } = scaleLimitsRef.current; - - if (min === null || max === null) { - return value; - } - - const clampedValue = Math.max(min, Math.min(max, value)); - - return clampedValue; - }, []); - const { selectedLanguage } = useGeolocationStore(); - const positionRef = useRef(position); - const scaleRef = useRef(scale); - const setPositionRef = useRef(setPosition); - const setScaleRef = useRef(setScale); - - // Обновляем refs при изменении функций - useEffect(() => { - setPositionRef.current = setPosition; - }, [setPosition]); - - useEffect(() => { - setScaleRef.current = setScale; - }, [setScale]); - - // Логирование данных маршрута для отладки - useEffect(() => { - if (routeData) { - } - }, [routeData]); - - useEffect(() => { - positionRef.current = position; - }, [position]); - - useEffect(() => { - scaleRef.current = scale; - }, [scale]); - - const rotationAngle = useMemo(() => { - const deg = (routeData as any)?.rotate ?? 0; - return (deg * Math.PI) / 180; - }, [routeData]); - - const { - position: animatedYellowDotPosition, - animateTo: animateYellowDotTo, - setPositionImmediate: setYellowDotPositionImmediate, - } = useAnimatedPolarPosition(0, 0, 800); - - // Build transformed route path (map coords) - const routePath = useMemo(() => { - if (!routeData?.path || routeData?.path.length === 0) - return new Float32Array(); - const centerLat = routeData.center_latitude; - const centerLon = routeData.center_longitude; - if (centerLat === undefined || centerLon === undefined) - return new Float32Array(); - - const cos = Math.cos(rotationAngle); - const sin = Math.sin(rotationAngle); - - const verts: number[] = []; - for (const [lat, lon] of routeData.path) { - const local = coordinatesToLocal(lat - centerLat, lon - centerLon); - const x = local.x * UP_SCALE; - const y = local.y * UP_SCALE; - const rx = x * cos - y * sin; - const ry = x * sin + y * cos; - verts.push(rx, ry); - } - return new Float32Array(verts); - }, [ - routeData?.path, - routeData?.center_latitude, - routeData?.center_longitude, - rotationAngle, - ]); - - const transformedTramCoords = useMemo(() => { - const centerLat = routeData?.center_latitude; - const centerLon = routeData?.center_longitude; - if (centerLat === undefined || centerLon === undefined) return null; - - const coords: any = apiStore?.context?.currentCoordinates; - if (!coords) return null; - - const local = coordinatesToLocal( - coords.latitude - centerLat, - coords.longitude - centerLon - ); - const x = local.x * UP_SCALE; - const y = local.y * UP_SCALE; - const cos = Math.cos(rotationAngle); - const sin = Math.sin(rotationAngle); - const rx = x * cos - y * sin; - const ry = x * sin + y * cos; - - return { x: rx, y: ry }; - }, [ - routeData?.center_latitude, - routeData?.center_longitude, - apiStore?.context?.currentCoordinates, - rotationAngle, - ]); - - // Настройка CameraAnimationStore callback - только один раз при монтировании - useEffect(() => { - const callback = (newPos: { x: number; y: number }, newZoom: number) => { - setPosition(newPos); - setScale(newZoom); - }; - - cameraAnimationStore.setUpdateCallback(callback); - - // Синхронизируем начальное состояние только один раз - cameraAnimationStore.syncState(position, scale); - - return () => { - cameraAnimationStore.setUpdateCallback(null); - }; - }, []); // Пустой массив - выполняется только при монтировании - - // Установка границ зума - useEffect(() => { - if ( - routeData?.scale_min !== undefined && - routeData?.scale_max !== undefined - ) { - cameraAnimationStore.setMaxZoom(routeData.scale_max / SCALE_FACTOR); - cameraAnimationStore.setMinZoom(routeData.scale_min / SCALE_FACTOR); - } - }, [routeData?.scale_min, routeData?.scale_max, cameraAnimationStore]); - - // Автоматический режим - таймер для включения через 5 секунд бездействия - useEffect(() => { - const interval = setInterval(() => { - const timeSinceActivity = Date.now() - userActivityTimestamp; - if (timeSinceActivity >= 5000 && !isAutoMode) { - // 5 секунд бездействия - включаем авто режим - setIsAutoMode(true); - } - }, 1000); // Проверяем каждую секунду - - return () => clearInterval(interval); - }, [userActivityTimestamp, isAutoMode, setIsAutoMode]); - - // Следование за желтой точкой с зумом при включенном авто режиме - useEffect(() => { - // Пропускаем обновление если анимация уже идет - if (cameraAnimationStore.isActivelyAnimating) { - return; - } - - if (isAutoMode && transformedTramCoords && screenCenter) { - // Преобразуем станции в формат для CameraAnimationStore - const transformedStations = stationData - ? stationData - .map((station: any) => { - const centerLat = routeData?.center_latitude; - const centerLon = routeData?.center_longitude; - if (centerLat === undefined || centerLon === undefined) - return null; - - const local = coordinatesToLocal( - station.latitude - centerLat, - station.longitude - centerLon - ); - const x = local.x * UP_SCALE; - const y = local.y * UP_SCALE; - const cos = Math.cos(rotationAngle); - const sin = Math.sin(rotationAngle); - const rx = x * cos - y * sin; - const ry = x * sin + y * cos; - - return { - longitude: rx, - latitude: ry, - id: station.id, - }; - }) - .filter(Boolean) - : []; - - if ( - transformedTramCoords && - screenCenter && - transformedStations && - scaleLimitsRef.current !== null && - scaleLimitsRef.current.max !== null && - scaleLimitsRef.current.min && - scaleLimitsRef.current.min !== null - ) { - cameraAnimationStore.setMaxZoom(scaleLimitsRef.current!.max); - cameraAnimationStore.setMinZoom(scaleLimitsRef.current!.min); - - // Синхронизируем текущее состояние камеры перед запуском анимации - cameraAnimationStore.syncState(positionRef.current, scaleRef.current); - - // Запускаем анимацию к желтой точке - cameraAnimationStore.followTram( - transformedTramCoords, - screenCenter, - transformedStations - ); - } - } else if (!isAutoMode) { - cameraAnimationStore.stopAnimation(); - } - }, [ - isAutoMode, - transformedTramCoords, - screenCenter, - cameraAnimationStore, - stationData, - routeData, - rotationAngle, - ]); - - // Station label overlay positions (DOM overlay) - const stationLabels = useMemo(() => { - if (!stationData || !routeData) - return [] as Array<{ x: number; y: number; name: string; sub?: string }>; - const centerLat = routeData.center_latitude; - const centerLon = routeData.center_longitude; - if (centerLat === undefined || centerLon === undefined) return []; - const cos = Math.cos(rotationAngle); - const sin = Math.sin(rotationAngle); - const result: Array<{ x: number; y: number; name: string; sub?: string }> = - []; - for (let i = 0; i < stationData.length; i++) { - const st = stationData[i]; - const local = coordinatesToLocal( - st.latitude - centerLat, - st.longitude - centerLon - ); - const x = local.x * UP_SCALE; - const y = local.y * UP_SCALE; - const rx = x * cos - y * sin; - const ry = x * sin + y * cos; - const DEFAULT_LABEL_OFFSET_X = 25; - const DEFAULT_LABEL_OFFSET_Y = 0; - const labelOffsetX = - st.offset_x === 0 && st.offset_y === 0 - ? DEFAULT_LABEL_OFFSET_X - : st.offset_x; - const labelOffsetY = - st.offset_x === 0 && st.offset_y === 0 - ? DEFAULT_LABEL_OFFSET_Y - : st.offset_y; - const textBlockPositionX = rx + labelOffsetX; - const textBlockPositionY = ry + labelOffsetY; - const dpr = Math.max( - 1, - (typeof window !== "undefined" && window.devicePixelRatio) || 1 - ); - const sx = (textBlockPositionX * scale + position.x) / dpr; - const sy = (textBlockPositionY * scale + position.y) / dpr; - let sub: string | undefined; - if ((selectedLanguage as any) === "zh") - sub = (stationDataZh as any)?.[i]?.name; - else if ( - (selectedLanguage as any) === "en" || - (selectedLanguage as any) === "ru" - ) - sub = (stationDataEn as any)?.[i]?.name; - result.push({ x: sx, y: sy, name: st.name, sub }); - } - return result; - }, [ - stationData, - stationDataEn as any, - stationDataZh as any, - position.x, - position.y, - scale, - routeData?.center_latitude, - routeData?.center_longitude, - rotationAngle, - selectedLanguage as any, - ]); - - // Build transformed stations (map coords) - const stationPoints = useMemo(() => { - if (!stationData || !routeData) return new Float32Array(); - const centerLat = routeData.center_latitude; - const centerLon = routeData.center_longitude; - if (centerLat === undefined || centerLon === undefined) - return new Float32Array(); - - const cos = Math.cos(rotationAngle); - const sin = Math.sin(rotationAngle); - const verts: number[] = []; - for (const s of stationData) { - const local = coordinatesToLocal( - s.latitude - centerLat, - s.longitude - centerLon - ); - const x = local.x * UP_SCALE; - const y = local.y * UP_SCALE; - const rx = x * cos - y * sin; - const ry = x * sin + y * cos; - verts.push(rx, ry); - } - return new Float32Array(verts); - }, [ - stationData, - routeData?.center_latitude, - routeData?.center_longitude, - rotationAngle, - ]); - - // Build transformed sights (map coords) - const sightPoints = useMemo(() => { - if (!sightData || !routeData) return new Float32Array(); - const centerLat = routeData.center_latitude; - const centerLon = routeData.center_longitude; - if (centerLat === undefined || centerLon === undefined) - return new Float32Array(); - - const cos = Math.cos(rotationAngle); - const sin = Math.sin(rotationAngle); - const verts: number[] = []; - for (const s of sightData) { - const local = coordinatesToLocal( - s.latitude - centerLat, - s.longitude - centerLon - ); - const x = local.x * UP_SCALE; - const y = local.y * UP_SCALE; - const rx = x * cos - y * sin; - const ry = x * sin + y * cos; - verts.push(rx, ry); - } - return new Float32Array(verts); - }, [ - sightData, - routeData?.center_latitude, - routeData?.center_longitude, - rotationAngle, - ]); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const gl = initWebGLContext(canvas); - glRef.current = gl; - if (!gl) return; - - const vertSrc = ` - attribute vec2 a_pos; - uniform vec2 u_cameraPos; - uniform float u_scale; - uniform vec2 u_resolution; - void main() { - vec2 screen = a_pos * u_scale + u_cameraPos; - vec2 zeroToOne = screen / u_resolution; - vec2 zeroToTwo = zeroToOne * 2.0; - vec2 clip = zeroToTwo - 1.0; - gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); - } - `; - const fragSrc = ` - precision mediump float; - uniform vec4 u_color; - void main() { - gl_FragColor = u_color; - } - `; - - const compile = (type: number, src: string) => { - const s = gl.createShader(type)!; - gl.shaderSource(s, src); - gl.compileShader(s); - return s; - }; - const vs = compile(gl.VERTEX_SHADER, vertSrc); - const fs = compile(gl.FRAGMENT_SHADER, fragSrc); - const prog = gl.createProgram()!; - gl.attachShader(prog, vs); - gl.attachShader(prog, fs); - gl.linkProgram(prog); - programRef.current = prog; - gl.useProgram(prog); - - const a_pos = gl.getAttribLocation(prog, "a_pos"); - const u_cameraPos = gl.getUniformLocation(prog, "u_cameraPos"); - const u_scale = gl.getUniformLocation(prog, "u_scale"); - const u_resolution = gl.getUniformLocation(prog, "u_resolution"); - const u_color = gl.getUniformLocation(prog, "u_color"); - attribsRef.current = { a_pos }; - uniformsRef.current = { u_cameraPos, u_scale, u_resolution, u_color }; - - const buffer = gl.createBuffer(); - bufferRef.current = buffer; - - const pointVert = ` - attribute vec2 a_pos; - uniform vec2 u_cameraPos; - uniform float u_scale; - uniform vec2 u_resolution; - uniform float u_pointSize; - void main() { - vec2 screen = a_pos * u_scale + u_cameraPos; - vec2 zeroToOne = screen / u_resolution; - vec2 zeroToTwo = zeroToOne * 2.0; - vec2 clip = zeroToTwo - 1.0; - gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); - gl_PointSize = u_pointSize; - } - `; - const pointFrag = ` - precision mediump float; - uniform vec4 u_color; - void main() { - vec2 c = gl_PointCoord * 2.0 - 1.0; - float d = dot(c, c); - if (d > 1.0) discard; - gl_FragColor = u_color; - } - `; - const vs2 = compile(gl.VERTEX_SHADER, pointVert); - const fs2 = compile(gl.FRAGMENT_SHADER, pointFrag); - const pprog = gl.createProgram()!; - gl.attachShader(pprog, vs2); - gl.attachShader(pprog, fs2); - gl.linkProgram(pprog); - pointProgramRef.current = pprog; - pointBufferRef.current = gl.createBuffer(); - - const lineVert = ` - attribute vec2 a_screen; - uniform vec2 u_resolution; - void main(){ - vec2 zeroToOne = a_screen / u_resolution; - vec2 clip = zeroToOne * 2.0 - 1.0; - gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); - } - `; - const lineFrag = ` - precision mediump float; - uniform vec4 u_color; - void main(){ gl_FragColor = u_color; } - `; - const lv = compile(gl.VERTEX_SHADER, lineVert); - const lf = compile(gl.FRAGMENT_SHADER, lineFrag); - const lprog = gl.createProgram()!; - gl.attachShader(lprog, lv); - gl.attachShader(lprog, lf); - gl.linkProgram(lprog); - screenLineProgramRef.current = lprog; - screenLineBufferRef.current = gl.createBuffer(); - - const handleResize = () => { - const changed = resizeCanvasToDisplaySize(canvas); - if (!gl) return; - // Update screen center when canvas size changes - // Use physical pixels (canvas.width) instead of CSS pixels - setScreenCenter({ - x: canvas.width / 2, - y: canvas.height / 2, - }); - if (changed) { - gl.viewport(0, 0, canvas.width, canvas.height); - } - gl.clearColor(0, 0, 0, 1); - gl.clear(gl.COLOR_BUFFER_BIT); - }; - - handleResize(); - window.addEventListener("resize", handleResize); - return () => { - window.removeEventListener("resize", handleResize); - }; - }, []); - - useEffect(() => { - const centerLat = routeData?.center_latitude; - const centerLon = routeData?.center_longitude; - if (centerLat !== undefined && centerLon !== undefined) { - const coords: any = apiStore?.context?.currentCoordinates; - if (coords) { - const local = coordinatesToLocal( - coords.latitude - centerLat, - coords.longitude - centerLon - ); - const x = local.x * UP_SCALE; - const y = local.y * UP_SCALE; - const cos = Math.cos(rotationAngle), - sin = Math.sin(rotationAngle); - const rx = x * cos - y * sin; - const ry = x * sin + y * cos; - - // В авторежиме используем анимацию, иначе мгновенное обновление - if (isAutoMode) { - animateYellowDotTo(rx, ry); - } else { - setYellowDotPositionImmediate(rx, ry); - } - } - } - }, [ - routeData?.center_latitude, - routeData?.center_longitude, - rotationAngle, - apiStore?.context?.currentCoordinates?.latitude, - apiStore?.context?.currentCoordinates?.longitude, - isAutoMode, - animateYellowDotTo, - setYellowDotPositionImmediate, - ]); - - useEffect(() => { - const gl = glRef.current; - const canvas = canvasRef.current; - const prog = programRef.current; - const buffer = bufferRef.current; - const attribs = attribsRef.current; - const uniforms = uniformsRef.current; - const pprog = pointProgramRef.current; - const pbuffer = pointBufferRef.current; - if ( - !gl || - !canvas || - !prog || - !buffer || - !attribs || - !uniforms || - !pprog || - !pbuffer - ) - return; - - gl.viewport(0, 0, canvas.width, canvas.height); - gl.clearColor(0, 0, 0, 1); - gl.clear(gl.COLOR_BUFFER_BIT); - - gl.useProgram(prog); - gl.uniform2f(uniforms.u_cameraPos, position.x, position.y); - gl.uniform1f(uniforms.u_scale, scale); - gl.uniform2f(uniforms.u_resolution, canvas.width, canvas.height); - gl.bindBuffer(gl.ARRAY_BUFFER, buffer); - gl.bufferData(gl.ARRAY_BUFFER, routePath, gl.STATIC_DRAW); - gl.enableVertexAttribArray(attribs.a_pos); - gl.vertexAttribPointer(attribs.a_pos, 2, gl.FLOAT, false, 0, 0); - - const vcount = routePath.length / 2; - let tramSegIndex = -1; - { - const centerLatTmp = routeData?.center_latitude; - const centerLonTmp = routeData?.center_longitude; - if (centerLatTmp !== undefined && centerLonTmp !== undefined) { - const coordsAny: any = apiStore?.context?.currentCoordinates; - if (coordsAny) { - const loc = coordinatesToLocal( - coordsAny.latitude - centerLatTmp, - coordsAny.longitude - centerLonTmp - ); - const wx = loc.x * UP_SCALE; - const wy = loc.y * UP_SCALE; - const cosR = Math.cos(rotationAngle), - sinR = Math.sin(rotationAngle); - const tX = wx * cosR - wy * sinR; - const tY = wx * sinR + wy * cosR; - let best = -1, - bestD = Infinity; - for (let i = 0; i < vcount - 1; i++) { - const p1x = routePath[i * 2], - p1y = routePath[i * 2 + 1]; - const p2x = routePath[(i + 1) * 2], - p2y = routePath[(i + 1) * 2 + 1]; - const dx = p2x - p1x, - dy = p2y - p1y; - const len2 = dx * dx + dy * dy; - if (!len2) continue; - const t = ((tX - p1x) * dx + (tY - p1y) * dy) / len2; - const cl = Math.max(0, Math.min(1, t)); - const px = p1x + cl * dx, - py = p1y + cl * dy; - const d = Math.hypot(tX - px, tY - py); - if (d < bestD) { - bestD = d; - best = i; - } - } - tramSegIndex = best; - } - } - } - - const vertexCount = routePath.length / 2; - if (vertexCount > 1) { - // Generate thick line geometry using triangles with proper joins - const generateThickLine = (points: Float32Array, width: number) => { - const vertices: number[] = []; - const halfWidth = width / 2; - - if (points.length < 4) return new Float32Array(); - - // Process each segment - for (let i = 0; i < points.length - 2; i += 2) { - const x1 = points[i]; - const y1 = points[i + 1]; - const x2 = points[i + 2]; - const y2 = points[i + 3]; - - // Calculate perpendicular vector - const dx = x2 - x1; - const dy = y2 - y1; - const length = Math.sqrt(dx * dx + dy * dy); - if (length === 0) continue; - - const perpX = (-dy / length) * halfWidth; - const perpY = (dx / length) * halfWidth; - - // Create quad (two triangles) for this line segment - // Triangle 1 - vertices.push(x1 + perpX, y1 + perpY); - vertices.push(x1 - perpX, y1 - perpY); - vertices.push(x2 + perpX, y2 + perpY); - - // Triangle 2 - vertices.push(x1 - perpX, y1 - perpY); - vertices.push(x2 - perpX, y2 - perpY); - vertices.push(x2 + perpX, y2 + perpY); - - // Add simple join triangles to fill gaps - if (i < points.length - 4) { - const x3 = points[i + 4]; - const y3 = points[i + 5]; - - const dx2 = x3 - x2; - const dy2 = y3 - y2; - const length2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); - if (length2 > 0) { - const perpX2 = (-dy2 / length2) * halfWidth; - const perpY2 = (dx2 / length2) * halfWidth; - - // Simple join - just connect the endpoints - vertices.push(x2 + perpX, y2 + perpY); - vertices.push(x2 - perpX, y2 - perpY); - vertices.push(x2 + perpX2, y2 + perpY2); - - vertices.push(x2 - perpX, y2 - perpY); - vertices.push(x2 - perpX2, y2 - perpY2); - vertices.push(x2 + perpX2, y2 + perpY2); - } - } - } - - return new Float32Array(vertices); - }; - - const lineWidth = Math.min(6); - const r1 = ((PATH_COLOR >> 16) & 0xff) / 255; - const g1 = ((PATH_COLOR >> 8) & 0xff) / 255; - const b1 = (PATH_COLOR & 0xff) / 255; - gl.uniform4f(uniforms.u_color, r1, g1, b1, 1); - - if (tramSegIndex >= 0) { - // Используем точную позицию желтой точки для определения конца красной линии - const animatedPos = animatedYellowDotPosition; - if ( - animatedPos && - animatedPos.x !== undefined && - animatedPos.y !== undefined - ) { - // Создаем массив точек от начала маршрута до позиции желтой точки - const passedPoints: number[] = []; - - // Добавляем все точки до текущего сегмента - for (let i = 0; i <= tramSegIndex; i++) { - passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]); - } - - // Добавляем точную позицию желтой точки как конечную точку - passedPoints.push(animatedPos.x, animatedPos.y); - - if (passedPoints.length >= 4) { - const thickLineVertices = generateThickLine( - new Float32Array(passedPoints), - lineWidth - ); - gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW); - gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2); - } - } - } - - const r2 = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255; - const g2 = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255; - const b2 = (UNPASSED_STATION_COLOR & 0xff) / 255; - gl.uniform4f(uniforms.u_color, r2, g2, b2, 1); - - // Серая линия начинается точно от позиции желтой точки - const animatedPos = animatedYellowDotPosition; - if ( - animatedPos && - animatedPos.x !== undefined && - animatedPos.y !== undefined - ) { - const unpassedPoints: number[] = []; - - // Добавляем позицию желтой точки как начальную точку серой линии - unpassedPoints.push(animatedPos.x, animatedPos.y); - - // Добавляем все точки после текущего сегмента - for (let i = tramSegIndex + 1; i < vertexCount; i++) { - unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]); - } - - if (unpassedPoints.length >= 4) { - const thickLineVertices = generateThickLine( - new Float32Array(unpassedPoints), - lineWidth - ); - gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW); - gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2); - } - } - } - - // Draw stations - if (stationPoints.length > 0) { - gl.useProgram(pprog); - const a_pos_pts = gl.getAttribLocation(pprog, "a_pos"); - const u_cameraPos_pts = gl.getUniformLocation(pprog, "u_cameraPos"); - const u_scale_pts = gl.getUniformLocation(pprog, "u_scale"); - const u_resolution_pts = gl.getUniformLocation(pprog, "u_resolution"); - const u_pointSize = gl.getUniformLocation(pprog, "u_pointSize"); - const u_color_pts = gl.getUniformLocation(pprog, "u_color"); - - gl.uniform2f(u_cameraPos_pts, position.x, position.y); - gl.uniform1f(u_scale_pts, scale); - gl.uniform2f(u_resolution_pts, canvas.width, canvas.height); - gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); - gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW); - gl.enableVertexAttribArray(a_pos_pts); - gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); - - // Draw station outlines (black background) - gl.uniform1f(u_pointSize, 10 * scale * 1.5); - const r_outline = ((BACKGROUND_COLOR >> 16) & 0xff) / 255; - const g_outline = ((BACKGROUND_COLOR >> 8) & 0xff) / 255; - const b_outline = (BACKGROUND_COLOR & 0xff) / 255; - gl.uniform4f(u_color_pts, r_outline, g_outline, b_outline, 1); - gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2); - - // Draw station cores (colored based on passed/unpassed) - gl.uniform1f(u_pointSize, 8.0 * scale * 1.5); - - // Draw passed stations (red) - if (tramSegIndex >= 0) { - const passedStations = []; - for (let i = 0; i < stationData.length; i++) { - if (i <= tramSegIndex) { - // @ts-ignore - passedStations.push(stationPoints[i * 2], stationPoints[i * 2 + 1]); - } - } - if (passedStations.length > 0) { - const r_passed = ((PATH_COLOR >> 16) & 0xff) / 255; - const g_passed = ((PATH_COLOR >> 8) & 0xff) / 255; - const b_passed = (PATH_COLOR & 0xff) / 255; - gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1); - gl.bufferData( - gl.ARRAY_BUFFER, - new Float32Array(passedStations), - gl.STATIC_DRAW - ); - gl.drawArrays(gl.POINTS, 0, passedStations.length / 2); - } - } - - // Draw unpassed stations (gray) - if (tramSegIndex >= 0) { - const unpassedStations = []; - for (let i = 0; i < stationData.length; i++) { - if (i > tramSegIndex) { - unpassedStations.push( - // @ts-ignore - stationPoints[i * 2], - stationPoints[i * 2 + 1] - ); - } - } - if (unpassedStations.length > 0) { - const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255; - const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255; - const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255; - gl.uniform4f(u_color_pts, r_unpassed, g_unpassed, b_unpassed, 1); - gl.bufferData( - gl.ARRAY_BUFFER, - new Float32Array(unpassedStations), - gl.STATIC_DRAW - ); - gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2); - } - } else { - // If no tram position, draw all stations as unpassed - const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255; - const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255; - const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255; - gl.uniform4f(u_color_pts, r_unpassed, g_unpassed, b_unpassed, 1); - gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW); - gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2); - } - } - - const a_pos_pts = gl.getAttribLocation(pprog, "a_pos"); - const u_cameraPos_pts = gl.getUniformLocation(pprog, "u_cameraPos"); - const u_scale_pts = gl.getUniformLocation(pprog, "u_scale"); - const u_resolution_pts = gl.getUniformLocation(pprog, "u_resolution"); - const u_pointSize = gl.getUniformLocation(pprog, "u_pointSize"); - const u_color_pts = gl.getUniformLocation(pprog, "u_color"); - - gl.uniform2f(u_cameraPos_pts, position.x, position.y); - gl.uniform1f(u_scale_pts, scale); - gl.uniform2f(u_resolution_pts, canvas.width, canvas.height); - - const toPointsArray = (arr: number[]) => new Float32Array(arr); - - const pathPts: { x: number; y: number }[] = []; - for (let i = 0; i < routePath.length; i += 2) - pathPts.push({ x: routePath[i], y: routePath[i + 1] }); - const getSeg = (px: number, py: number) => { - if (pathPts.length < 2) return -1; - let best = -1, - bestD = Infinity; - for (let i = 0; i < pathPts.length - 1; i++) { - const p1 = pathPts[i], - p2 = pathPts[i + 1]; - const dx = p2.x - p1.x, - dy = p2.y - p1.y; - const len2 = dx * dx + dy * dy; - if (!len2) continue; - const t = ((px - p1.x) * dx + (py - p1.y) * dy) / len2; - const tt = Math.max(0, Math.min(1, t)); - const cx = p1.x + tt * dx, - cy = p1.y + tt * dy; - const d = Math.hypot(px - cx, py - cy); - if (d < bestD) { - bestD = d; - best = i; - } - } - return best; - }; - - let tramSegForStations = -1; - { - const cLat = routeData?.center_latitude, - cLon = routeData?.center_longitude; - const tram = apiStore?.context?.currentCoordinates as any; - if (tram && cLat !== undefined && cLon !== undefined) { - const loc = coordinatesToLocal( - tram.latitude - cLat, - tram.longitude - cLon - ); - const wx = loc.x * UP_SCALE, - wy = loc.y * UP_SCALE; - const cosR = Math.cos(rotationAngle), - sinR = Math.sin(rotationAngle); - const tx = wx * cosR - wy * sinR, - ty = wx * sinR + wy * cosR; - tramSegForStations = getSeg(tx, ty); - } - } - - const passedStations: number[] = []; - const unpassedStations: number[] = []; - for (let i = 0; i < stationPoints.length; i += 2) { - const sx = stationPoints[i], - sy = stationPoints[i + 1]; - const seg = getSeg(sx, sy); - if (tramSegForStations !== -1 && seg !== -1 && seg < tramSegForStations) - passedStations.push(sx, sy); - else unpassedStations.push(sx, sy); - } - - const outlineSize = 10.0 * scale * 2, - coreSize = 8.0 * scale * 2; - - gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); - gl.bufferData( - gl.ARRAY_BUFFER, - toPointsArray(unpassedStations), - gl.STREAM_DRAW - ); - gl.enableVertexAttribArray(a_pos_pts); - gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); - gl.uniform1f(u_pointSize, outlineSize); - gl.uniform4f( - u_color_pts, - ((BACKGROUND_COLOR >> 16) & 255) / 255, - ((BACKGROUND_COLOR >> 8) & 255) / 255, - (BACKGROUND_COLOR & 255) / 255, - 1 - ); - if (unpassedStations.length) - gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2); - gl.uniform1f(u_pointSize, coreSize); - gl.uniform4f( - u_color_pts, - ((UNPASSED_STATION_COLOR >> 16) & 255) / 255, - ((UNPASSED_STATION_COLOR >> 8) & 255) / 255, - (UNPASSED_STATION_COLOR & 255) / 255, - 1 - ); - if (unpassedStations.length) - gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2); - - gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); - gl.bufferData( - gl.ARRAY_BUFFER, - toPointsArray(passedStations), - gl.STREAM_DRAW - ); - gl.enableVertexAttribArray(a_pos_pts); - gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); - gl.uniform1f(u_pointSize, outlineSize); - gl.uniform4f( - u_color_pts, - ((BACKGROUND_COLOR >> 16) & 255) / 255, - ((BACKGROUND_COLOR >> 8) & 255) / 255, - (BACKGROUND_COLOR & 255) / 255, - 1 - ); - if (passedStations.length) - gl.drawArrays(gl.POINTS, 0, passedStations.length / 2); - gl.uniform1f(u_pointSize, coreSize); - gl.uniform4f( - u_color_pts, - ((PATH_COLOR >> 16) & 255) / 255, - ((PATH_COLOR >> 8) & 255) / 255, - (PATH_COLOR & 255) / 255, - 1 - ); - if (passedStations.length) - gl.drawArrays(gl.POINTS, 0, passedStations.length / 2); - - // Draw black dots with white outline for terminal stations (startStopId and endStopId) - 5x larger - if ( - stationData && - stationData.length > 0 && - routeData && - apiStore?.context - ) { - const centerLat = routeData.center_latitude; - const centerLon = routeData.center_longitude; - if (centerLat !== undefined && centerLon !== undefined) { - const cos = Math.cos(rotationAngle); - const sin = Math.sin(rotationAngle); - - // Find terminal stations using startStopId and endStopId from context - const startStationData = stationData.find( - (station) => station.id.toString() === apiStore.context?.startStopId - ); - const endStationData = stationData.find( - (station) => station.id.toString() === apiStore.context?.endStopId - ); - - const terminalStations: number[] = []; - - // Transform start station coordinates if found - if (startStationData) { - const startLocal = coordinatesToLocal( - startStationData.latitude - centerLat, - startStationData.longitude - centerLon - ); - const startX = startLocal.x * UP_SCALE; - const startY = startLocal.y * UP_SCALE; - const startRx = startX * cos - startY * sin; - const startRy = startX * sin + startY * cos; - terminalStations.push(startRx, startRy); - } - - // Transform end station coordinates if found - if (endStationData) { - const endLocal = coordinatesToLocal( - endStationData.latitude - centerLat, - endStationData.longitude - centerLon - ); - const endX = endLocal.x * UP_SCALE; - const endY = endLocal.y * UP_SCALE; - const endRx = endX * cos - endY * sin; - const endRy = endX * sin + endY * cos; - terminalStations.push(endRx, endRy); - } - - if (terminalStations.length > 0) { - // Determine if each terminal station is passed - const terminalStationData: any[] = []; - if (startStationData) terminalStationData.push(startStationData); - if (endStationData) terminalStationData.push(endStationData); - - // Get tram segment index for comparison - let tramSegIndex = -1; - const coords: any = apiStore?.context?.currentCoordinates; - if (coords && centerLat !== undefined && centerLon !== undefined) { - const local = coordinatesToLocal( - coords.latitude - centerLat, - coords.longitude - centerLon - ); - const wx = local.x * UP_SCALE; - const wy = local.y * UP_SCALE; - const cosR = Math.cos(rotationAngle); - const sinR = Math.sin(rotationAngle); - const tx = wx * cosR - wy * sinR; - const ty = wx * sinR + wy * cosR; - - // Find closest segment to tram position - let best = -1; - let bestD = Infinity; - for (let i = 0; i < routePath.length - 2; i += 2) { - const p1x = routePath[i]; - const p1y = routePath[i + 1]; - const p2x = routePath[i + 2]; - const p2y = routePath[i + 3]; - const dx = p2x - p1x; - const dy = p2y - p1y; - const len2 = dx * dx + dy * dy; - if (!len2) continue; - const t = ((tx - p1x) * dx + (ty - p1y) * dy) / len2; - const cl = Math.max(0, Math.min(1, t)); - const px = p1x + cl * dx; - const py = p1y + cl * dy; - const d = Math.hypot(tx - px, ty - py); - if (d < bestD) { - bestD = d; - best = i / 2; - } - } - tramSegIndex = best; - } - - // Check if each terminal station is passed - const isStartPassed = startStationData - ? (() => { - const sx = terminalStations[0]; - const sy = terminalStations[1]; - const seg = (() => { - if (routePath.length < 4) return -1; - let best = -1; - let bestD = Infinity; - for (let i = 0; i < routePath.length - 2; i += 2) { - const p1x = routePath[i]; - const p1y = routePath[i + 1]; - const p2x = routePath[i + 2]; - const p2y = routePath[i + 3]; - const dx = p2x - p1x; - const dy = p2y - p1y; - const len2 = dx * dx + dy * dy; - if (!len2) continue; - const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2; - const cl = Math.max(0, Math.min(1, t)); - const px = p1x + cl * dx; - const py = p1y + cl * dy; - const d = Math.hypot(sx - px, sy - py); - if (d < bestD) { - bestD = d; - best = i / 2; - } - } - return best; - })(); - return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex; - })() - : false; - - const isEndPassed = endStationData - ? (() => { - const ex = terminalStations[terminalStations.length - 2]; - const ey = terminalStations[terminalStations.length - 1]; - const seg = (() => { - if (routePath.length < 4) return -1; - let best = -1; - let bestD = Infinity; - for (let i = 0; i < routePath.length - 2; i += 2) { - const p1x = routePath[i]; - const p1y = routePath[i + 1]; - const p2x = routePath[i + 2]; - const p2y = routePath[i + 3]; - const dx = p2x - p1x; - const dy = p2y - p1y; - const len2 = dx * dx + dy * dy; - if (!len2) continue; - const t = ((ex - p1x) * dx + (ey - p1y) * dy) / len2; - const cl = Math.max(0, Math.min(1, t)); - const px = p1x + cl * dx; - const py = p1y + cl * dy; - const d = Math.hypot(ex - px, ey - py); - if (d < bestD) { - bestD = d; - best = i / 2; - } - } - return best; - })(); - return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex; - })() - : false; - - gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); - gl.bufferData( - gl.ARRAY_BUFFER, - new Float32Array(terminalStations), - gl.STREAM_DRAW - ); - gl.enableVertexAttribArray(a_pos_pts); - gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); - - // Draw colored outline based on passed status - 24 pixels (x2) - gl.uniform1f(u_pointSize, 18.0 * scale); - if (startStationData && endStationData) { - // Both stations - draw each with its own color - if (isStartPassed) { - gl.uniform4f(u_color_pts, 1.0, 0.4, 0.4, 1.0); // Ярко-красный для пройденных - } else { - gl.uniform4f(u_color_pts, 0.7, 0.7, 0.7, 1.0); // Светло-серый для непройденных - } - gl.drawArrays(gl.POINTS, 0, 1); // Draw start station - - if (isEndPassed) { - gl.uniform4f(u_color_pts, 1.0, 0.4, 0.4, 1.0); // Ярко-красный для пройденных - } else { - gl.uniform4f(u_color_pts, 0.7, 0.7, 0.7, 1.0); // Светло-серый для непройденных - } - gl.drawArrays(gl.POINTS, 1, 1); // Draw end station - } else { - // Single station - use appropriate color - const isPassed = startStationData ? isStartPassed : isEndPassed; - if (isPassed) { - gl.uniform4f(u_color_pts, 1.0, 0.4, 0.4, 1.0); // Ярко-красный для пройденных - } else { - gl.uniform4f(u_color_pts, 0.7, 0.7, 0.7, 1.0); // Светло-серый для непройденных - } - gl.drawArrays(gl.POINTS, 0, terminalStations.length / 2); - } - - // Draw dark center - 12 pixels (x2) - gl.uniform1f(u_pointSize, 11.0 * scale); - const r_center = ((BACKGROUND_COLOR >> 16) & 0xff) / 255; - const g_center = ((BACKGROUND_COLOR >> 8) & 0xff) / 255; - const b_center = (BACKGROUND_COLOR & 0xff) / 255; - gl.uniform4f(u_color_pts, r_center, g_center, b_center, 1.0); // Dark color - gl.drawArrays(gl.POINTS, 0, terminalStations.length / 2); - } - } - } - - // Draw yellow dot for tram position - if (animatedYellowDotPosition) { - const rx = animatedYellowDotPosition.x; - const ry = animatedYellowDotPosition.y; - - gl.uniform1f(u_pointSize, 13.3333 * scale); - gl.uniform4f(u_color_pts, 1.0, 1.0, 0.0, 1.0); - const tmp = new Float32Array([rx, ry]); - gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); - gl.bufferData(gl.ARRAY_BUFFER, tmp, gl.STREAM_DRAW); - gl.enableVertexAttribArray(a_pos_pts); - gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); - gl.drawArrays(gl.POINTS, 0, 1); - } - }, [ - routePath, - stationPoints, - sightPoints, - position.x, - position.y, - scale, - animatedYellowDotPosition?.x, - animatedYellowDotPosition?.y, - ]); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - if (!routePath || routePath.length < 4) return; - - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity; - for (let i = 0; i < routePath.length; i += 2) { - const x = routePath[i]; - const y = routePath[i + 1]; - if (x < minX) minX = x; - if (y < minY) minY = y; - if (x > maxX) maxX = x; - if (y > maxY) maxY = y; - } - if ( - !isFinite(minX) || - !isFinite(minY) || - !isFinite(maxX) || - !isFinite(maxY) - ) - return; - - const worldWidth = Math.max(1, maxX - minX); - const worldHeight = Math.max(1, maxY - minY); - - const margin = 0.1; - const targetScale = Math.min( - (canvas.width * (1 - margin)) / worldWidth, - (canvas.height * (1 - margin)) / worldHeight - ); - - const centerX = (minX + maxX) / 2; - const centerY = (minY + maxY) / 2; - - const clampedScale = clampScale(targetScale); - - setScaleRef.current(clampedScale); - setPositionRef.current({ - x: canvas.width / 2 - centerX * clampedScale, - y: canvas.height / 2 - centerY * clampedScale, - }); - }, [routePath]); - - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - - let isDragging = false; - let startMouse = { x: 0, y: 0 }; - let startPos = { x: 0, y: 0 }; - - const activePointers = new Map(); - let isPinching = false; - let pinchStart: { - distance: number; - midpoint: { x: number; y: number }; - scale: number; - position: { x: number; y: number }; - } | null = null; - - const getDistance = ( - p1: { x: number; y: number }, - p2: { x: number; y: number } - ) => Math.hypot(p2.x - p1.x, p2.y - p1.y); - - const getMidpoint = ( - p1: { x: number; y: number }, - p2: { x: number; y: number } - ) => ({ - x: (p1.x + p2.x) / 2, - y: (p1.y + p2.y) / 2, - }); - - const onPointerDown = (e: PointerEvent) => { - // Отслеживаем активность пользователя - updateUserActivity(); - if (isAutoMode) { - setIsAutoMode(false); - } - cameraAnimationStore.stopAnimation(); - - canvas.setPointerCapture(e.pointerId); - const rect = canvas.getBoundingClientRect(); - activePointers.set(e.pointerId, { - x: e.clientX - rect.left, - y: e.clientY - rect.top, - }); - if (activePointers.size === 1) { - isDragging = true; - startMouse = { x: e.clientX - rect.left, y: e.clientY - rect.top }; - startPos = { x: positionRef.current.x, y: positionRef.current.y }; - } else if (activePointers.size === 2) { - isDragging = false; - const [p1, p2] = Array.from(activePointers.values()); - pinchStart = { - distance: getDistance(p1, p2), - midpoint: getMidpoint(p1, p2), - scale: scaleRef.current, - position: { x: positionRef.current.x, y: positionRef.current.y }, - }; - isPinching = true; - } - }; - - const onPointerMove = (e: PointerEvent) => { - if (!activePointers.has(e.pointerId)) return; - - // Отслеживаем активность пользователя - updateUserActivity(); - - const rect = canvas.getBoundingClientRect(); - activePointers.set(e.pointerId, { - x: e.clientX - rect.left, - y: e.clientY - rect.top, - }); - - if (activePointers.size === 2) { - isDragging = false; - - const pointersArray = Array.from(activePointers.values()); - if (pointersArray.length === 2) { - const [p1, p2] = pointersArray; - - if (!isPinching || pinchStart === null) { - isPinching = true; - pinchStart = { - distance: getDistance(p1, p2), - midpoint: getMidpoint(p1, p2), - scale: scaleRef.current, - position: { x: positionRef.current.x, y: positionRef.current.y }, - }; - } - - // Process the pinch gesture - if (pinchStart) { - const currentDistance = getDistance(p1, p2); - const zoomFactor = currentDistance / pinchStart.distance; - const unclampedScale = pinchStart.scale * zoomFactor; - const newScale = clampScale(Math.max(0.1, unclampedScale)); - - const k = newScale / pinchStart.scale; - const newPosition = { - x: pinchStart.position.x * k + pinchStart.midpoint.x * (1 - k), - y: pinchStart.position.y * k + pinchStart.midpoint.y * (1 - k), - }; - setPositionRef.current(newPosition); - setScaleRef.current(newScale); - } - } - } else if (isDragging && activePointers.size === 1) { - const p = Array.from(activePointers.values())[0]; - - // Проверяем валидность значений - if ( - !startMouse || - !startPos || - typeof startMouse.x !== "number" || - typeof startMouse.y !== "number" || - typeof startPos.x !== "number" || - typeof startPos.y !== "number" - ) { - console.warn( - "WebGLMap: Некорректные значения startMouse или startPos:", - { - startMouse, - startPos, - p, - } - ); - return; - } - - const dx = p.x - startMouse.x; - const dy = p.y - startMouse.y; - - setPositionRef.current({ x: startPos.x + dx, y: startPos.y + dy }); - } - }; - - const onPointerUp = (e: PointerEvent) => { - // Отслеживаем активность пользователя - updateUserActivity(); - - canvas.releasePointerCapture(e.pointerId); - activePointers.delete(e.pointerId); - if (activePointers.size < 2) { - isPinching = false; - pinchStart = null; - } - if (activePointers.size === 0) { - isDragging = false; - } else if (activePointers.size === 1) { - const p = Array.from(activePointers.values())[0]; - startPos = { x: positionRef.current.x, y: positionRef.current.y }; - startMouse = { x: p.x, y: p.y }; - isDragging = true; - } - }; - - const onPointerCancel = (e: PointerEvent) => { - // Handle pointer cancellation (e.g., when touch is interrupted) - updateUserActivity(); - canvas.releasePointerCapture(e.pointerId); - activePointers.delete(e.pointerId); - isPinching = false; - pinchStart = null; - if (activePointers.size === 0) { - isDragging = false; - } - }; - - const onWheel = (e: WheelEvent) => { - e.preventDefault(); - - // Отслеживаем активность пользователя - updateUserActivity(); - if (isAutoMode) { - setIsAutoMode(false); - } - cameraAnimationStore.stopAnimation(); - - const rect = canvas.getBoundingClientRect(); - // Convert mouse coordinates from CSS pixels to physical canvas pixels - const mouseX = - (e.clientX - rect.left) * (canvas.width / canvas.clientWidth); - const mouseY = - (e.clientY - rect.top) * (canvas.height / canvas.clientHeight); - const delta = e.deltaY > 0 ? 0.9 : 1.1; - const unclampedScale = scaleRef.current * delta; - const newScale = clampScale(Math.max(0.1, unclampedScale)); - - const k = newScale / scaleRef.current; - const newPosition = { - x: positionRef.current.x * k + mouseX * (1 - k), - y: positionRef.current.y * k + mouseY * (1 - k), - }; - setScaleRef.current(newScale); - setPositionRef.current(newPosition); - }; - - canvas.addEventListener("pointerdown", onPointerDown); - canvas.addEventListener("pointermove", onPointerMove); - canvas.addEventListener("pointerup", onPointerUp); - canvas.addEventListener("pointercancel", onPointerCancel); - canvas.addEventListener("pointerleave", onPointerUp); - canvas.addEventListener("wheel", onWheel, { passive: false }); - - return () => { - canvas.removeEventListener("pointerdown", onPointerDown); - canvas.removeEventListener("pointermove", onPointerMove); - canvas.removeEventListener("pointerup", onPointerUp); - canvas.removeEventListener("pointercancel", onPointerCancel); - canvas.removeEventListener("pointerleave", onPointerUp); - canvas.removeEventListener("wheel", onWheel as any); - }; - }, [ - updateUserActivity, - setIsAutoMode, - cameraAnimationStore, - isAutoMode, - clampScale, - ]); - - return ( -
- -
- {stationLabels.map((l, idx) => ( -
-
{l.name}
- {l.sub ? ( -
- {l.sub} -
- ) : null} -
- ))} - {sightData?.map((s: any, i: number) => { - const centerLat = routeData?.center_latitude; - const centerLon = routeData?.center_longitude; - if (centerLat === undefined || centerLon === undefined) return null; - const cos = Math.cos(rotationAngle); - const sin = Math.sin(rotationAngle); - const local = coordinatesToLocal( - s.latitude - centerLat, - s.longitude - centerLon - ); - const x = local.x * UP_SCALE; - const y = local.y * UP_SCALE; - const rx = x * cos - y * sin; - const ry = x * sin + y * cos; - const dpr = Math.max( - 1, - (typeof window !== "undefined" && window.devicePixelRatio) || 1 - ); - const sx = (rx * scale + position.x) / dpr; - const sy = (ry * scale + position.y) / dpr; - const size = 30; - - // Обработчик клика для выбора достопримечательности - const handleSightClick = () => { - const { - setSelectedSightId, - setIsManualSelection, - setIsRightWidgetSelectorOpen, - closeGovernorModal, - } = useGeolocationStore(); - setSelectedSightId(String(s.id)); - setIsManualSelection(true); - setIsRightWidgetSelectorOpen(false); - closeGovernorModal(); - }; - - return ( - - ); - })} - - {(() => { - if (!routeData) return null; - const centerLat = routeData.center_latitude; - const centerLon = routeData.center_longitude; - if (centerLat === undefined || centerLon === undefined) return null; - - const coords: any = apiStore?.context?.currentCoordinates; - if (!coords) return null; - - const local = coordinatesToLocal( - coords.latitude - centerLat, - coords.longitude - centerLon - ); - const wx = local.x * UP_SCALE; - const wy = local.y * UP_SCALE; - const cosR = Math.cos(rotationAngle); - const sinR = Math.sin(rotationAngle); - const rx = wx * cosR - wy * sinR; - const ry = wx * sinR + wy * cosR; - const dpr2 = Math.max( - 1, - (typeof window !== "undefined" && window.devicePixelRatio) || 1 - ); - const screenX = (rx * scale + position.x) / dpr2; - const screenY = (ry * scale + position.y) / dpr2; - - const pathPts: { x: number; y: number }[] = []; - for (let i = 0; i < routePath.length; i += 2) - pathPts.push({ x: routePath[i], y: routePath[i + 1] }); - const stationsForAngle = (stationData || []).map((st: any) => { - const loc = coordinatesToLocal( - st.latitude - centerLat, - st.longitude - centerLon - ); - const x = loc.x * UP_SCALE, - y = loc.y * UP_SCALE; - const rx2 = x * cosR - y * sinR, - ry2 = x * sinR + y * cosR; - return { - longitude: rx2, - latitude: ry2, - offset_x: st.offset_x, - offset_y: st.offset_y, - }; - }); - let tramSegIndex = -1; - if (routePath.length >= 4) { - let best = -1, - bestD = Infinity; - for (let i = 0; i < routePath.length - 2; i += 2) { - const p1x = routePath[i], - p1y = routePath[i + 1]; - const p2x = routePath[i + 2], - p2y = routePath[i + 3]; - const dx = p2x - p1x, - dy = p2y - p1y; - const len2 = dx * dx + dy * dy; - if (!len2) continue; - const t = ((rx - p1x) * dx + (ry - p1y) * dy) / len2; - const cl = Math.max(0, Math.min(1, t)); - const px = p1x + cl * dx, - py = p1y + cl * dy; - const d = Math.hypot(rx - px, ry - py); - if (d < bestD) { - bestD = d; - best = i / 2; - } - } - tramSegIndex = best; - } - const optimalAngle = (() => { - const testRadiusInMap = 100 / scale; - const minPath = 60, - minPassed = 60, - minStation = 50; - let bestAng = 0, - bestScore = Infinity; - for (let i = 0; i < 12; i++) { - const ang = (i * Math.PI * 2) / 12; - const tx = rx + Math.cos(ang) * testRadiusInMap; - const ty = ry + Math.sin(ang) * testRadiusInMap; - const distPath = (function () { - if (pathPts.length < 2) return Infinity; - let md = Infinity; - for (let k = 0; k < pathPts.length - 1; k++) { - const p1 = pathPts[k], - p2 = pathPts[k + 1]; - const L2 = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; - if (!L2) continue; - const tt = - ((tx - p1.x) * (p2.x - p1.x) + - (ty - p1.y) * (p2.y - p1.y)) / - L2; - const cl = Math.max(0, Math.min(1, tt)); - const px = p1.x + cl * (p2.x - p1.x), - py = p1.y + cl * (p2.y - p1.y); - const d = Math.hypot(tx - px, ty - py); - if (d < md) md = d; - } - return md * scale; - })(); - const distPassed = (function () { - if (pathPts.length < 2 || tramSegIndex < 0) return Infinity; - let md = Infinity; - for ( - let k = 0; - k <= Math.min(tramSegIndex, pathPts.length - 2); - k++ - ) { - const p1 = pathPts[k], - p2 = pathPts[k + 1]; - const L2 = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; - if (!L2) continue; - const tt = - ((tx - p1.x) * (p2.x - p1.x) + - (ty - p1.y) * (p2.y - p1.y)) / - L2; - const cl = Math.max(0, Math.min(1, tt)); - const px = p1.x + cl * (p2.x - p1.x), - py = p1.y + cl * (p2.y - p1.y); - const d = Math.hypot(tx - px, ty - py); - if (d < md) md = d; - } - return md * scale; - })(); - const distStation = (function () { - if (!stationsForAngle.length) return Infinity; - const DEFAULT_LABEL_OFFSET_X = 25, - DEFAULT_LABEL_OFFSET_Y = 0; - let md = Infinity; - for (const st of stationsForAngle) { - const offsetX = - st.offset_x === 0 && st.offset_y === 0 - ? DEFAULT_LABEL_OFFSET_X - : st.offset_x || 0 * 3; - const offsetY = - st.offset_x === 0 && st.offset_y === 0 - ? DEFAULT_LABEL_OFFSET_Y - : st.offset_y || 0 * 3; - const lx = st.longitude + offsetX, - ly = st.latitude + offsetY; - const d = Math.hypot(tx - lx, ty - ly); - if (d < md) md = d; - } - return md * scale; - })(); - let weight = 0; - if (distPath < minPath) weight += 100 * (1 - distPath / minPath); - if (distPassed < minPassed) - weight += 10 * (1 - distPassed / minPassed); - if (distStation < minStation) - weight += 1000 * (1 - distStation / minStation); - if (weight < bestScore) { - bestScore = weight; - bestAng = ang; - } - } - return bestAng; - })(); - - return ( - - ); - })()} -
-
- ); -}); - -export default WebGLMap; diff --git a/src/pages/Route/route-preview/webgl-prototype/WebGLRouteMapPrototype.tsx b/src/pages/Route/route-preview/webgl-prototype/WebGLRouteMapPrototype.tsx index b203455..18778b1 100644 --- a/src/pages/Route/route-preview/webgl-prototype/WebGLRouteMapPrototype.tsx +++ b/src/pages/Route/route-preview/webgl-prototype/WebGLRouteMapPrototype.tsx @@ -370,6 +370,7 @@ export const WebGLRouteMapPrototype = observer(() => { setSelectedSight, setStationOffset, setSightCoordinates, + setMapCenter, } = useMapData(); const { language } = languageStore; const { setScale: setSharedScale, scale: sharedScale } = useTransform(); @@ -446,6 +447,12 @@ export const WebGLRouteMapPrototype = observer(() => { latitude: number | null; longitude: number | null; }>({ latitude: null, longitude: null }); + const pendingCenterRef = useRef<{ + latitude: number; + longitude: number; + } | null>(null); + const isUserInteractingRef = useRef(false); + const commitCenterTimeoutRef = useRef(null); const getRelativePointerPosition = useCallback( (clientX: number, clientY: number) => { @@ -532,6 +539,58 @@ export const WebGLRouteMapPrototype = observer(() => { [rotationAngle] ); + const cancelScheduledCenterCommit = useCallback(() => { + if (commitCenterTimeoutRef.current !== null) { + window.clearTimeout(commitCenterTimeoutRef.current); + commitCenterTimeoutRef.current = null; + } + }, []); + + const commitCenter = useCallback(() => { + const center = lastCenterRef.current; + if ( + !center || + center.latitude == null || + center.longitude == null || + !Number.isFinite(center.latitude) || + !Number.isFinite(center.longitude) + ) { + return; + } + + const epsilon = 1e-7; + const prev = lastAppliedCenterRef.current; + if ( + prev.latitude != null && + prev.longitude != null && + Math.abs(prev.latitude - center.latitude) < epsilon && + Math.abs(prev.longitude - center.longitude) < epsilon + ) { + return; + } + + lastAppliedCenterRef.current = { + latitude: center.latitude, + longitude: center.longitude, + }; + setMapCenter(center.latitude, center.longitude); + }, [setMapCenter]); + + const scheduleCenterCommit = useCallback(() => { + cancelScheduledCenterCommit(); + commitCenterTimeoutRef.current = window.setTimeout(() => { + commitCenterTimeoutRef.current = null; + isUserInteractingRef.current = false; + commitCenter(); + }, 120); + }, [cancelScheduledCenterCommit, commitCenter]); + + useEffect(() => { + return () => { + cancelScheduledCenterCommit(); + }; + }, [cancelScheduledCenterCommit]); + const updateTransform = useCallback( (next: Transform) => { const adjusted = clampTransformScale(next); @@ -1026,6 +1085,39 @@ export const WebGLRouteMapPrototype = observer(() => { max: baseScale * 16, }; } + const centerLat = + routeData?.center_latitude ?? originalRouteData?.center_latitude; + const centerLon = + routeData?.center_longitude ?? originalRouteData?.center_longitude; + if ( + Number.isFinite(centerLat) && + Number.isFinite(centerLon) && + canvas.width > 0 && + canvas.height > 0 + ) { + const local = coordinatesToLocal( + centerLat as number, + centerLon as number + ); + const baseX = local.x * UP_SCALE; + const baseY = local.y * UP_SCALE; + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const rotatedX = baseX * cos - baseY * sin; + const rotatedY = baseX * sin + baseY * cos; + const scale = transform.scale || 1; + transform = { + scale, + translation: { + x: canvas.width / 2 - rotatedX * scale, + y: canvas.height / 2 - rotatedY * scale, + }, + }; + lastAppliedCenterRef.current = { + latitude: centerLat as number, + longitude: centerLon as number, + }; + } transform = clampTransformScale(transform); updateTransform(transform); } else { @@ -1260,8 +1352,75 @@ export const WebGLRouteMapPrototype = observer(() => { latitude: roundedLat, longitude: roundedLon, }; + + if (isUserInteractingRef.current) { + pendingCenterRef.current = { + latitude: roundedLat, + longitude: roundedLon, + }; + return; + } + + const transform = + transformRef.current ?? lastTransformRef.current ?? transformState; + const canvas = canvasRef.current; + + if (!canvas || !transform) { + pendingCenterRef.current = { + latitude: roundedLat, + longitude: roundedLon, + }; + return; + } + + const width = canvas.width || canvas.clientWidth; + const height = canvas.height || canvas.clientHeight; + if (!width || !height) { + pendingCenterRef.current = { + latitude: roundedLat, + longitude: roundedLon, + }; + return; + } + + const local = coordinatesToLocal(roundedLat, roundedLon); + const baseX = local.x * UP_SCALE; + const baseY = local.y * UP_SCALE; + + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const rotatedX = baseX * cos - baseY * sin; + const rotatedY = baseX * sin + baseY * cos; + + const scale = transform.scale || 1; + const targetTranslation = { + x: width / 2 - rotatedX * scale, + y: height / 2 - rotatedY * scale, + }; + + const currentTranslation = transform.translation; + const distance = Math.hypot( + targetTranslation.x - currentTranslation.x, + targetTranslation.y - currentTranslation.y + ); + + if (distance < 0.5) { + pendingCenterRef.current = null; + return; + } + + const nextTransform: Transform = { + scale, + translation: targetTranslation, + }; + + transformRef.current = nextTransform; + lastTransformRef.current = nextTransform; + setTransformState(nextTransform); + drawSceneRef.current(); + pendingCenterRef.current = null; }, - [] + [rotationAngle, setTransformState, transformState] ); useEffect(() => { @@ -1290,6 +1449,18 @@ export const WebGLRouteMapPrototype = observer(() => { applyCenterFromCoordinates, ]); + useEffect(() => { + if (!pendingCenterRef.current || !transformRef.current) { + return; + } + + const { latitude, longitude } = pendingCenterRef.current; + pendingCenterRef.current = null; + window.requestAnimationFrame(() => { + applyCenterFromCoordinates(latitude, longitude); + }); + }, [transformState, applyCenterFromCoordinates]); + useEffect(() => { const canvas = canvasRef.current; if (!canvas) { @@ -1333,6 +1504,8 @@ export const WebGLRouteMapPrototype = observer(() => { } event.preventDefault(); + isUserInteractingRef.current = true; + cancelScheduledCenterCommit(); const position = getEventPosition(event); activePointersRef.current.set(event.pointerId, position); canvas.setPointerCapture(event.pointerId); @@ -1420,6 +1593,7 @@ export const WebGLRouteMapPrototype = observer(() => { dragStateRef.current = null; pinchStateRef.current = null; canvas.style.cursor = "grab"; + scheduleCenterCommit(); } else if (activePointersRef.current.size === 1) { const remaining = Array.from(activePointersRef.current.values())[0]; dragStateRef.current = { lastPos: remaining }; @@ -1432,6 +1606,8 @@ export const WebGLRouteMapPrototype = observer(() => { event.preventDefault(); const transform = transformRef.current; if (!transform) return; + isUserInteractingRef.current = true; + cancelScheduledCenterCommit(); const position = getEventPosition(event); const delta = event.deltaY > 0 ? 0.9 : 1.1; @@ -1459,6 +1635,7 @@ export const WebGLRouteMapPrototype = observer(() => { }, }); drawSceneRef.current(); + scheduleCenterCommit(); }; canvas.addEventListener("pointerdown", handlePointerDown); @@ -1476,7 +1653,7 @@ export const WebGLRouteMapPrototype = observer(() => { canvas.removeEventListener("pointerleave", handlePointerUp); canvas.removeEventListener("wheel", handleWheel as EventListener); }; - }, []); + }, [cancelScheduledCenterCommit, scheduleCenterCommit, updateTransform]); return (
{ + const handleBulkLink = async () => { if (selectedItems.size === 0) return; setError(null); setIsLinkingBulk(true); - Promise.all( - Array.from(selectedItems).map((id) => - authInstance.post(`/${parentResource}/${parentId}/${childResource}`, { + const idsToLink = Array.from(selectedItems); + const linkedIds: number[] = []; + const failedIds: number[] = []; + + for (const id of idsToLink) { + try { + await authInstance.post(`/${parentResource}/${parentId}/${childResource}`, { station_id: id, - }) - ) - ) - .then(() => { - const newItems = allItems.filter((item) => - selectedItems.has(item.id) - ); - setLinkedItems([...linkedItems, ...newItems]); - setSelectedItems(new Set()); - onUpdate?.(); - }) - .catch((error) => { - console.error("Error bulk linking stations:", error); - setError("Failed to link stations"); - }) - .finally(() => { - setIsLinkingBulk(false); + }); + linkedIds.push(id); + } catch (error) { + console.error("Error linking station:", error); + failedIds.push(id); + } + } + + if (linkedIds.length > 0) { + const newItems = allItems.filter((item) => linkedIds.includes(item.id)); + setLinkedItems((prev) => { + const existingIds = new Set(prev.map((item) => item.id)); + const additions = newItems.filter((item) => !existingIds.has(item.id)); + return [...prev, ...additions]; }); + onUpdate?.(); + } + + setSelectedItems((prev) => { + if (linkedIds.length === 0) { + return prev; + } + const remaining = new Set(prev); + linkedIds.forEach((id) => remaining.delete(id)); + return failedIds.length > 0 ? remaining : new Set(); + }); + + if (failedIds.length > 0) { + setError( + failedIds.length === idsToLink.length + ? "Failed to link stations" + : "Some stations failed to link" + ); + } + + setIsLinkingBulk(false); }; const toggleDetachSelection = (itemId: number) => { @@ -269,7 +291,7 @@ const LinkedStationsContentsInner = < setSelectedToDetach(new Set(linkedItems.map((item) => item.id))); }; - const handleBulkDetach = () => { + const handleBulkDetach = async () => { const idsToDetach = Array.from(selectedToDetach); if (idsToDetach.length === 0) return; setError(null); @@ -281,32 +303,47 @@ const LinkedStationsContentsInner = < return next; }); - Promise.all( - idsToDetach.map((itemId) => - authInstance.delete(`/${parentResource}/${parentId}/${childResource}`, { + const detachedIds: number[] = []; + const failedIds: number[] = []; + + for (const itemId of idsToDetach) { + try { + await authInstance.delete(`/${parentResource}/${parentId}/${childResource}`, { data: { [`${childResource}_id`]: itemId }, - }) - ) - ) - .then(() => { - setLinkedItems( - linkedItems.filter((item) => !idsToDetach.includes(item.id)) - ); - setSelectedToDetach(new Set()); - onUpdate?.(); - }) - .catch((error) => { - console.error("Error bulk deleting stations:", error); - setError("Failed to delete stations"); - }) - .finally(() => { - setDetachingIds((prev) => { - const next = new Set(prev); - idsToDetach.forEach((id) => next.delete(id)); - return next; }); - setIsBulkDetaching(false); + detachedIds.push(itemId); + } catch (error) { + console.error("Error deleting station:", error); + failedIds.push(itemId); + } + } + + if (detachedIds.length > 0) { + setLinkedItems((prev) => + prev.filter((item) => !detachedIds.includes(item.id)) + ); + setSelectedToDetach((prev) => { + const remaining = new Set(prev); + detachedIds.forEach((id) => remaining.delete(id)); + return failedIds.length > 0 ? remaining : new Set(); }); + onUpdate?.(); + } + + if (failedIds.length > 0) { + setError( + failedIds.length === idsToDetach.length + ? "Failed to delete stations" + : "Some stations failed to delete" + ); + } + + setDetachingIds((prev) => { + const next = new Set(prev); + idsToDetach.forEach((id) => next.delete(id)); + return next; + }); + setIsBulkDetaching(false); }; const allSelectedForDetach = diff --git a/src/pages/Station/LinkedSights.tsx b/src/pages/Station/LinkedSights.tsx index a5b610c..07e0e4b 100644 --- a/src/pages/Station/LinkedSights.tsx +++ b/src/pages/Station/LinkedSights.tsx @@ -223,33 +223,58 @@ const LinkedSightsContentsInner = < setSelectedItems(updated); }; - const handleBulkLink = () => { + const handleBulkLink = async () => { if (selectedItems.size === 0) return; setError(null); setIsLinkingBulk(true); - Promise.all( - Array.from(selectedItems).map((id) => - authInstance.post(`/${parentResource}/${parentId}/${childResource}`, { - sight_id: id, - }) - ) - ) - .then(() => { - const newItems = allItems.filter((item) => - selectedItems.has(item.id) + const idsToLink = Array.from(selectedItems); + const linkedIds: number[] = []; + const failedIds: number[] = []; + + for (const id of idsToLink) { + try { + await authInstance.post( + `/${parentResource}/${parentId}/${childResource}`, + { + sight_id: id, + } ); - setLinkedItems([...linkedItems, ...newItems]); - setSelectedItems(new Set()); - onUpdate?.(); - }) - .catch((error) => { - console.error("Error bulk linking sights:", error); - setError("Failed to link sights"); - }) - .finally(() => { - setIsLinkingBulk(false); + linkedIds.push(id); + } catch (error) { + console.error("Error linking sight:", error); + failedIds.push(id); + } + } + + if (linkedIds.length > 0) { + const newItems = allItems.filter((item) => linkedIds.includes(item.id)); + setLinkedItems((prev) => { + const existingIds = new Set(prev.map((item) => item.id)); + const additions = newItems.filter((item) => !existingIds.has(item.id)); + return [...prev, ...additions]; }); + onUpdate?.(); + } + + setSelectedItems((prev) => { + if (linkedIds.length === 0) { + return prev; + } + const remaining = new Set(prev); + linkedIds.forEach((id) => remaining.delete(id)); + return failedIds.length > 0 ? remaining : new Set(); + }); + + if (failedIds.length > 0) { + setError( + failedIds.length === idsToLink.length + ? "Failed to link sights" + : "Some sights failed to link" + ); + } + + setIsLinkingBulk(false); }; const toggleDetachSelection = (itemId: number) => { @@ -270,7 +295,7 @@ const LinkedSightsContentsInner = < setSelectedToDetach(new Set(linkedItems.map((item) => item.id))); }; - const handleBulkDetach = () => { + const handleBulkDetach = async () => { const idsToDetach = Array.from(selectedToDetach); if (idsToDetach.length === 0) return; setError(null); @@ -282,32 +307,50 @@ const LinkedSightsContentsInner = < return next; }); - Promise.all( - idsToDetach.map((itemId) => - authInstance.delete(`/${parentResource}/${parentId}/${childResource}`, { - data: { [`${childResource}_id`]: itemId }, - }) - ) - ) - .then(() => { - setLinkedItems( - linkedItems.filter((item) => !idsToDetach.includes(item.id)) + const detachedIds: number[] = []; + const failedIds: number[] = []; + + for (const itemId of idsToDetach) { + try { + await authInstance.delete( + `/${parentResource}/${parentId}/${childResource}`, + { + data: { [`${childResource}_id`]: itemId }, + } ); - setSelectedToDetach(new Set()); - onUpdate?.(); - }) - .catch((error) => { - console.error("Error bulk deleting sights:", error); - setError("Failed to delete sights"); - }) - .finally(() => { - setDetachingIds((prev) => { - const next = new Set(prev); - idsToDetach.forEach((id) => next.delete(id)); - return next; - }); - setIsBulkDetaching(false); + detachedIds.push(itemId); + } catch (error) { + console.error("Error deleting sight:", error); + failedIds.push(itemId); + } + } + + if (detachedIds.length > 0) { + setLinkedItems((prev) => + prev.filter((item) => !detachedIds.includes(item.id)) + ); + setSelectedToDetach((prev) => { + const remaining = new Set(prev); + detachedIds.forEach((id) => remaining.delete(id)); + return failedIds.length > 0 ? remaining : new Set(); }); + onUpdate?.(); + } + + if (failedIds.length > 0) { + setError( + failedIds.length === idsToDetach.length + ? "Failed to delete sights" + : "Some sights failed to delete" + ); + } + + setDetachingIds((prev) => { + const next = new Set(prev); + idsToDetach.forEach((id) => next.delete(id)); + return next; + }); + setIsBulkDetaching(false); }; const allSelectedForDetach = @@ -465,8 +508,9 @@ const LinkedSightsContentsInner = < item.id === selectedItemId) || - null + availableItems?.find( + (item) => item.id === selectedItemId + ) || null } onChange={(_, newValue) => setSelectedItemId(newValue?.id || null) diff --git a/src/shared/store/SightsStore/index.tsx b/src/shared/store/SightsStore/index.tsx index d4dc4f0..4198c1f 100644 --- a/src/shared/store/SightsStore/index.tsx +++ b/src/shared/store/SightsStore/index.tsx @@ -132,12 +132,16 @@ class SightsStore { common: boolean ) => { if (common) { + // @ts-ignore this.sight!.common = { + // @ts-ignore ...this.sight!.common, ...content, }; } else { + // @ts-ignore this.sight![language] = { + // @ts-ignore ...this.sight![language], ...content, }; diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index d174581..ab33043 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/globalerrorboundary.tsx","./src/app/index.tsx","./src/app/router/index.tsx","./src/entities/index.ts","./src/entities/navigation/index.ts","./src/entities/navigation/model/index.ts","./src/entities/navigation/ui/index.tsx","./src/features/index.ts","./src/features/navigation/index.ts","./src/features/navigation/ui/index.tsx","./src/pages/index.ts","./src/pages/article/index.ts","./src/pages/article/articlecreatepage/index.tsx","./src/pages/article/articleeditpage/index.tsx","./src/pages/article/articlelistpage/index.tsx","./src/pages/article/articlepreviewpage/previewleftwidget.tsx","./src/pages/article/articlepreviewpage/previewrightwidget.tsx","./src/pages/article/articlepreviewpage/index.tsx","./src/pages/carrier/index.ts","./src/pages/carrier/carriercreatepage/index.tsx","./src/pages/carrier/carriereditpage/index.tsx","./src/pages/carrier/carrierlistpage/index.tsx","./src/pages/city/index.ts","./src/pages/city/citycreatepage/index.tsx","./src/pages/city/cityeditpage/index.tsx","./src/pages/city/citylistpage/index.tsx","./src/pages/city/citypreviewpage/index.tsx","./src/pages/country/index.ts","./src/pages/country/countryaddpage/index.tsx","./src/pages/country/countrycreatepage/index.tsx","./src/pages/country/countryeditpage/index.tsx","./src/pages/country/countrylistpage/index.tsx","./src/pages/country/countrypreviewpage/index.tsx","./src/pages/createsightpage/index.tsx","./src/pages/devicespage/index.tsx","./src/pages/editsightpage/index.tsx","./src/pages/loginpage/index.tsx","./src/pages/mainpage/index.tsx","./src/pages/mappage/index.tsx","./src/pages/mappage/mapstore.ts","./src/pages/media/index.ts","./src/pages/media/mediacreatepage/index.tsx","./src/pages/media/mediaeditpage/index.tsx","./src/pages/media/medialistpage/index.tsx","./src/pages/media/mediapreviewpage/index.tsx","./src/pages/route/linekedstations.tsx","./src/pages/route/index.ts","./src/pages/route/routecreatepage/index.tsx","./src/pages/route/routeeditpage/index.tsx","./src/pages/route/routelistpage/index.tsx","./src/pages/route/route-preview/constants.ts","./src/pages/route/route-preview/infinitecanvas.tsx","./src/pages/route/route-preview/leftsidebar.tsx","./src/pages/route/route-preview/mapdatacontext.tsx","./src/pages/route/route-preview/rightsidebar.tsx","./src/pages/route/route-preview/sight.tsx","./src/pages/route/route-preview/sightinfowidget.tsx","./src/pages/route/route-preview/station.tsx","./src/pages/route/route-preview/transformcontext.tsx","./src/pages/route/route-preview/travelpath.tsx","./src/pages/route/route-preview/widgets.tsx","./src/pages/route/route-preview/index.tsx","./src/pages/route/route-preview/types.ts","./src/pages/route/route-preview/utils.ts","./src/pages/route/route-preview/web-gl/webglmap.tsx","./src/pages/route/route-preview/webgl-prototype/webglroutemapprototype.tsx","./src/pages/sight/linkedstations.tsx","./src/pages/sight/index.ts","./src/pages/sight/sightlistpage/index.tsx","./src/pages/sightpage/index.tsx","./src/pages/snapshot/index.ts","./src/pages/snapshot/snapshotcreatepage/index.tsx","./src/pages/snapshot/snapshotlistpage/index.tsx","./src/pages/station/linkedsights.tsx","./src/pages/station/index.ts","./src/pages/station/stationcreatepage/index.tsx","./src/pages/station/stationeditpage/index.tsx","./src/pages/station/stationlistpage/index.tsx","./src/pages/station/stationpreviewpage/index.tsx","./src/pages/user/index.ts","./src/pages/user/usercreatepage/index.tsx","./src/pages/user/usereditpage/index.tsx","./src/pages/user/userlistpage/index.tsx","./src/pages/vehicle/index.ts","./src/pages/vehicle/vehiclecreatepage/index.tsx","./src/pages/vehicle/vehicleeditpage/index.tsx","./src/pages/vehicle/vehiclelistpage/index.tsx","./src/pages/vehicle/vehiclepreviewpage/index.tsx","./src/shared/index.tsx","./src/shared/api/index.tsx","./src/shared/config/constants.tsx","./src/shared/config/index.ts","./src/shared/const/index.ts","./src/shared/const/mediatypes.ts","./src/shared/hooks/index.ts","./src/shared/hooks/useselectedcity.ts","./src/shared/lib/gltfcachemanager.ts","./src/shared/lib/index.ts","./src/shared/lib/decodejwt/index.ts","./src/shared/lib/mui/theme.ts","./src/shared/modals/index.ts","./src/shared/modals/articleselectorcreatedialog/index.tsx","./src/shared/modals/previewmediadialog/index.tsx","./src/shared/modals/selectarticledialog/index.tsx","./src/shared/modals/selectmediadialog/index.tsx","./src/shared/modals/uploadmediadialog/index.tsx","./src/shared/store/index.ts","./src/shared/store/articlesstore/index.tsx","./src/shared/store/authstore/index.tsx","./src/shared/store/carrierstore/index.tsx","./src/shared/store/citystore/index.ts","./src/shared/store/countrystore/index.ts","./src/shared/store/createsightstore/index.tsx","./src/shared/store/devicesstore/index.tsx","./src/shared/store/editsightstore/index.tsx","./src/shared/store/languagestore/index.tsx","./src/shared/store/mediastore/index.tsx","./src/shared/store/menustore/index.ts","./src/shared/store/modelloadingstore/index.ts","./src/shared/store/routestore/index.ts","./src/shared/store/selectedcitystore/index.ts","./src/shared/store/sightsstore/index.tsx","./src/shared/store/snapshotstore/index.ts","./src/shared/store/stationsstore/index.ts","./src/shared/store/userstore/index.ts","./src/shared/store/vehiclestore/index.ts","./src/shared/ui/animatedcirclebutton.tsx","./src/shared/ui/index.ts","./src/shared/ui/backbutton/index.tsx","./src/shared/ui/coordinatesinput/index.tsx","./src/shared/ui/input/index.tsx","./src/shared/ui/loadingspinner/index.tsx","./src/shared/ui/modal/index.tsx","./src/shared/ui/modelloadingindicator/index.tsx","./src/shared/ui/tabpanel/index.tsx","./src/widgets/index.ts","./src/widgets/cityselector/index.tsx","./src/widgets/createbutton/index.tsx","./src/widgets/deletemodal/index.tsx","./src/widgets/devicestable/index.tsx","./src/widgets/imageuploadcard/index.tsx","./src/widgets/languageswitcher/index.tsx","./src/widgets/layout/index.tsx","./src/widgets/layout/ui/appbar.tsx","./src/widgets/layout/ui/drawer.tsx","./src/widgets/layout/ui/drawerheader.tsx","./src/widgets/leaveagree/index.tsx","./src/widgets/mediaarea/index.tsx","./src/widgets/mediaareaforsight/index.tsx","./src/widgets/mediaviewer/threeview.tsx","./src/widgets/mediaviewer/threeviewerrorboundary.tsx","./src/widgets/mediaviewer/index.tsx","./src/widgets/modelviewer3d/index.tsx","./src/widgets/reactmarkdown/index.tsx","./src/widgets/reactmarkdowneditor/index.tsx","./src/widgets/savewithoutcityagree/index.tsx","./src/widgets/sightedit/index.tsx","./src/widgets/sightheader/index.ts","./src/widgets/sightheader/ui/index.tsx","./src/widgets/sighttabs/index.ts","./src/widgets/sighttabs/createinformationtab/mediauploadbox.tsx","./src/widgets/sighttabs/createinformationtab/index.tsx","./src/widgets/sighttabs/createlefttab/index.tsx","./src/widgets/sighttabs/createrighttab/index.tsx","./src/widgets/sighttabs/informationtab/index.tsx","./src/widgets/sighttabs/leftwidgettab/index.tsx","./src/widgets/sighttabs/rightwidgettab/index.tsx","./src/widgets/sightstable/index.tsx","./src/widgets/snapshotrestore/index.tsx","./src/widgets/videopreviewcard/index.tsx","./src/widgets/modals/editstationmodal.tsx","./src/widgets/modals/index.ts","./src/widgets/modals/selectarticledialog/index.tsx"],"errors":true,"version":"5.8.3"} \ No newline at end of file +{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/globalerrorboundary.tsx","./src/app/index.tsx","./src/app/router/index.tsx","./src/entities/index.ts","./src/entities/navigation/index.ts","./src/entities/navigation/model/index.ts","./src/entities/navigation/ui/index.tsx","./src/features/index.ts","./src/features/navigation/index.ts","./src/features/navigation/ui/index.tsx","./src/pages/index.ts","./src/pages/article/index.ts","./src/pages/article/articlecreatepage/index.tsx","./src/pages/article/articleeditpage/index.tsx","./src/pages/article/articlelistpage/index.tsx","./src/pages/article/articlepreviewpage/previewleftwidget.tsx","./src/pages/article/articlepreviewpage/previewrightwidget.tsx","./src/pages/article/articlepreviewpage/index.tsx","./src/pages/carrier/index.ts","./src/pages/carrier/carriercreatepage/index.tsx","./src/pages/carrier/carriereditpage/index.tsx","./src/pages/carrier/carrierlistpage/index.tsx","./src/pages/city/index.ts","./src/pages/city/citycreatepage/index.tsx","./src/pages/city/cityeditpage/index.tsx","./src/pages/city/citylistpage/index.tsx","./src/pages/city/citypreviewpage/index.tsx","./src/pages/country/index.ts","./src/pages/country/countryaddpage/index.tsx","./src/pages/country/countrycreatepage/index.tsx","./src/pages/country/countryeditpage/index.tsx","./src/pages/country/countrylistpage/index.tsx","./src/pages/country/countrypreviewpage/index.tsx","./src/pages/createsightpage/index.tsx","./src/pages/devicespage/index.tsx","./src/pages/editsightpage/index.tsx","./src/pages/loginpage/index.tsx","./src/pages/mainpage/index.tsx","./src/pages/mappage/index.tsx","./src/pages/mappage/mapstore.ts","./src/pages/media/index.ts","./src/pages/media/mediacreatepage/index.tsx","./src/pages/media/mediaeditpage/index.tsx","./src/pages/media/medialistpage/index.tsx","./src/pages/media/mediapreviewpage/index.tsx","./src/pages/route/linekedstations.tsx","./src/pages/route/index.ts","./src/pages/route/routecreatepage/index.tsx","./src/pages/route/routeeditpage/index.tsx","./src/pages/route/routelistpage/index.tsx","./src/pages/route/route-preview/constants.ts","./src/pages/route/route-preview/infinitecanvas.tsx","./src/pages/route/route-preview/leftsidebar.tsx","./src/pages/route/route-preview/mapdatacontext.tsx","./src/pages/route/route-preview/rightsidebar.tsx","./src/pages/route/route-preview/sight.tsx","./src/pages/route/route-preview/sightinfowidget.tsx","./src/pages/route/route-preview/station.tsx","./src/pages/route/route-preview/transformcontext.tsx","./src/pages/route/route-preview/travelpath.tsx","./src/pages/route/route-preview/widgets.tsx","./src/pages/route/route-preview/index.tsx","./src/pages/route/route-preview/types.ts","./src/pages/route/route-preview/utils.ts","./src/pages/route/route-preview/web-gl/languageselector.tsx","./src/pages/route/route-preview/webgl-prototype/webglroutemapprototype.tsx","./src/pages/sight/linkedstations.tsx","./src/pages/sight/index.ts","./src/pages/sight/sightlistpage/index.tsx","./src/pages/sightpage/index.tsx","./src/pages/snapshot/index.ts","./src/pages/snapshot/snapshotcreatepage/index.tsx","./src/pages/snapshot/snapshotlistpage/index.tsx","./src/pages/station/linkedsights.tsx","./src/pages/station/index.ts","./src/pages/station/stationcreatepage/index.tsx","./src/pages/station/stationeditpage/index.tsx","./src/pages/station/stationlistpage/index.tsx","./src/pages/station/stationpreviewpage/index.tsx","./src/pages/user/index.ts","./src/pages/user/usercreatepage/index.tsx","./src/pages/user/usereditpage/index.tsx","./src/pages/user/userlistpage/index.tsx","./src/pages/vehicle/index.ts","./src/pages/vehicle/vehiclecreatepage/index.tsx","./src/pages/vehicle/vehicleeditpage/index.tsx","./src/pages/vehicle/vehiclelistpage/index.tsx","./src/pages/vehicle/vehiclepreviewpage/index.tsx","./src/shared/index.tsx","./src/shared/api/index.tsx","./src/shared/config/constants.tsx","./src/shared/config/index.ts","./src/shared/const/index.ts","./src/shared/const/mediatypes.ts","./src/shared/hooks/index.ts","./src/shared/hooks/useselectedcity.ts","./src/shared/lib/gltfcachemanager.ts","./src/shared/lib/index.ts","./src/shared/lib/decodejwt/index.ts","./src/shared/lib/mui/theme.ts","./src/shared/modals/index.ts","./src/shared/modals/articleselectorcreatedialog/index.tsx","./src/shared/modals/previewmediadialog/index.tsx","./src/shared/modals/selectarticledialog/index.tsx","./src/shared/modals/selectmediadialog/index.tsx","./src/shared/modals/uploadmediadialog/index.tsx","./src/shared/store/index.ts","./src/shared/store/articlesstore/index.tsx","./src/shared/store/authstore/index.tsx","./src/shared/store/carrierstore/index.tsx","./src/shared/store/citystore/index.ts","./src/shared/store/countrystore/index.ts","./src/shared/store/createsightstore/index.tsx","./src/shared/store/devicesstore/index.tsx","./src/shared/store/editsightstore/index.tsx","./src/shared/store/languagestore/index.tsx","./src/shared/store/mediastore/index.tsx","./src/shared/store/menustore/index.ts","./src/shared/store/modelloadingstore/index.ts","./src/shared/store/routestore/index.ts","./src/shared/store/selectedcitystore/index.ts","./src/shared/store/sightsstore/index.tsx","./src/shared/store/snapshotstore/index.ts","./src/shared/store/stationsstore/index.ts","./src/shared/store/userstore/index.ts","./src/shared/store/vehiclestore/index.ts","./src/shared/ui/animatedcirclebutton.tsx","./src/shared/ui/index.ts","./src/shared/ui/backbutton/index.tsx","./src/shared/ui/coordinatesinput/index.tsx","./src/shared/ui/input/index.tsx","./src/shared/ui/loadingspinner/index.tsx","./src/shared/ui/modal/index.tsx","./src/shared/ui/modelloadingindicator/index.tsx","./src/shared/ui/tabpanel/index.tsx","./src/widgets/index.ts","./src/widgets/cityselector/index.tsx","./src/widgets/createbutton/index.tsx","./src/widgets/deletemodal/index.tsx","./src/widgets/devicestable/index.tsx","./src/widgets/imageuploadcard/index.tsx","./src/widgets/languageswitcher/index.tsx","./src/widgets/layout/index.tsx","./src/widgets/layout/ui/appbar.tsx","./src/widgets/layout/ui/drawer.tsx","./src/widgets/layout/ui/drawerheader.tsx","./src/widgets/leaveagree/index.tsx","./src/widgets/mediaarea/index.tsx","./src/widgets/mediaareaforsight/index.tsx","./src/widgets/mediaviewer/threeview.tsx","./src/widgets/mediaviewer/threeviewerrorboundary.tsx","./src/widgets/mediaviewer/index.tsx","./src/widgets/modelviewer3d/index.tsx","./src/widgets/reactmarkdown/index.tsx","./src/widgets/reactmarkdowneditor/index.tsx","./src/widgets/savewithoutcityagree/index.tsx","./src/widgets/sightedit/index.tsx","./src/widgets/sightheader/index.ts","./src/widgets/sightheader/ui/index.tsx","./src/widgets/sighttabs/index.ts","./src/widgets/sighttabs/createinformationtab/mediauploadbox.tsx","./src/widgets/sighttabs/createinformationtab/index.tsx","./src/widgets/sighttabs/createlefttab/index.tsx","./src/widgets/sighttabs/createrighttab/index.tsx","./src/widgets/sighttabs/informationtab/index.tsx","./src/widgets/sighttabs/leftwidgettab/index.tsx","./src/widgets/sighttabs/rightwidgettab/index.tsx","./src/widgets/sightstable/index.tsx","./src/widgets/snapshotrestore/index.tsx","./src/widgets/videopreviewcard/index.tsx","./src/widgets/modals/editstationmodal.tsx","./src/widgets/modals/index.ts","./src/widgets/modals/selectarticledialog/index.tsx"],"version":"5.8.3"} \ No newline at end of file