diff --git a/src/client/src/api/ApiStore/store.ts b/src/client/src/api/ApiStore/store.ts index c04bcae..0214f67 100644 --- a/src/client/src/api/ApiStore/store.ts +++ b/src/client/src/api/ApiStore/store.ts @@ -56,7 +56,7 @@ class ApiStore { carrier: GetCarrierResponse | null = null; city: GetCityResponse | null = null; - private positionIndex = 0; + positionIndex = 0; private positionInterval: ReturnType | null = null; simulationSpeed = 1; diff --git a/src/client/src/components/map/WebGLMap.tsx b/src/client/src/components/map/WebGLMap.tsx index 57bae01..cfd39a0 100644 --- a/src/client/src/components/map/WebGLMap.tsx +++ b/src/client/src/components/map/WebGLMap.tsx @@ -797,7 +797,9 @@ export const WebGLMap = observer(() => { const textBlockPositionX = rx + labelOffsetX; const textBlockPositionY = ry + labelOffsetY; - const approximateTextWidth = st.name.length * fontSize * 0.6; + const nameLines = st.name.replace(/\\n/g, '\n').split('\n'); + const longestLine = nameLines.reduce((a: string, b: string) => a.length > b.length ? a : b, ''); + const approximateTextWidth = longestLine.length * fontSize * 0.6; const textWidthInMapCoords = approximateTextWidth / scale; let anchorXOffset = 0; @@ -827,8 +829,8 @@ export const WebGLMap = observer(() => { result.push({ x: sx, y: sy, - name: st.name, - sub, + name: st.name.replace(/\\n/g, '\n'), + sub: sub ? sub.replace(/\\n/g, '\n') : sub, anchorX: anchorX, anchorY: anchorY, distance: distanceInPixels, @@ -878,6 +880,32 @@ export const WebGLMap = observer(() => { rotationAngle, ]); + // Сегментный индекс каждой упорядоченной станции на routePath (canvas-пространство) + const orderedStationSegs = useMemo(() => { + if (!orderedRouteStations || !stationData || routePath.length < 4) return [] as number[]; + return (orderedRouteStations as any[]).map((ordStation) => { + const stIdx = stationData.findIndex((s: any) => String(s.id) === String(ordStation.id)); + if (stIdx < 0) return -1; + const sx = stationPoints[stIdx * 2]; + const sy = stationPoints[stIdx * 2 + 1]; + if (sx === undefined || sy === undefined) return -1; + 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 = ((sx - p1x) * dx + (sy - 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(sx - px, sy - py); + if (d < bestD) { bestD = d; best = i / 2; } + } + return best; + }); + }, [orderedRouteStations, stationData, stationPoints, routePath]); + const sightPoints = useMemo(() => { if (!sightData || !routeData) return new Float32Array(); const centerLat = routeData.center_latitude; @@ -1095,6 +1123,8 @@ export const WebGLMap = observer(() => { }; }, []); + const prevPositionIndexRef = useRef(-1); + useEffect(() => { const centerLat = routeData?.center_latitude; const centerLon = routeData?.center_longitude; @@ -1112,7 +1142,14 @@ export const WebGLMap = observer(() => { const rx = x * cos - y * sin; const ry = x * sin + y * cos; - if (apiStore.simulationInstantMove) { + const curIdx = apiStore.positionIndex; + const prevIdx = prevPositionIndexRef.current; + const pathLen = apiStore.route?.path?.length ?? 0; + const isWrap = prevIdx >= 0 && pathLen > 0 && + Math.abs(curIdx - prevIdx) > pathLen / 4; + prevPositionIndexRef.current = curIdx; + + if (apiStore.simulationInstantMove || isWrap) { setYellowDotImmediate(rx, ry); } else { animateYellowDotTo(rx, ry); @@ -1164,6 +1201,7 @@ export const WebGLMap = observer(() => { gl.vertexAttribPointer(attribs.a_pos, 2, gl.FLOAT, false, 0, 0); let tramSegIndex = getCurrentSegIndex(); + const simulationDirection = apiStore.simulationDirection; const dpr = Math.max(1, window.devicePixelRatio || 1); const desiredRouteWidthCss = 7; @@ -1261,23 +1299,69 @@ export const WebGLMap = observer(() => { 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); + const r2 = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255; + const g2 = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255; + const b2 = (UNPASSED_STATION_COLOR & 0xff) / 255; - if (tramSegIndex >= 0) { - const animatedPos = animatedYellowDotPosition; - if ( - animatedPos && - animatedPos.x !== undefined && - animatedPos.y !== undefined - ) { + const animatedPos = animatedYellowDotPosition; + if ( + tramSegIndex >= 0 && + animatedPos && + animatedPos.x !== undefined && + animatedPos.y !== undefined + ) { + if (simulationDirection === 1) { + // Вперёд: закрашено от начала до трамвая + gl.uniform4f(uniforms.u_color, r1, g1, b1, 1); 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); + } + gl.uniform4f(uniforms.u_color, r2, g2, b2, 1); + 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); + } + } else { + // Назад: закрашено от трамвая до конца + gl.uniform4f(uniforms.u_color, r2, g2, b2, 1); + const unpassedPoints: number[] = []; + for (let i = 0; i <= tramSegIndex; i++) { + unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]); + } + unpassedPoints.push(animatedPos.x, animatedPos.y); + 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); + } + gl.uniform4f(uniforms.u_color, r1, g1, b1, 1); + const passedPoints: number[] = []; + passedPoints.push(animatedPos.x, animatedPos.y); + for (let i = tramSegIndex + 1; i < vertexCount; i++) { + passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]); + } if (passedPoints.length >= 4) { const thickLineVertices = generateThickLine( new Float32Array(passedPoints), @@ -1287,30 +1371,16 @@ export const WebGLMap = observer(() => { 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]); + } else { + // Позиция трамвая неизвестна — рисуем весь маршрут серым + gl.uniform4f(uniforms.u_color, r2, g2, b2, 1); + const allPoints: number[] = []; + for (let i = 0; i < vertexCount; i++) { + allPoints.push(routePath[i * 2], routePath[i * 2 + 1]); } - - if (unpassedPoints.length >= 4) { + if (allPoints.length >= 4) { const thickLineVertices = generateThickLine( - new Float32Array(unpassedPoints), + new Float32Array(allPoints), lineWidth, ); gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW); @@ -1345,92 +1415,32 @@ export const WebGLMap = observer(() => { gl.uniform1f(u_pointSize, pointInnerSizePx); - let currentStationIndexInOrdered = -1; - if (currentStationId && orderedRouteStations) { - currentStationIndexInOrdered = orderedRouteStations.findIndex( - (station: any) => String(station.id) === String(currentStationId), - ); - } - - if ( - currentStationIndexInOrdered >= 0 && - orderedRouteStations && - stationData - ) { - const passedStations: number[] = []; - - for (let i = 0; i < currentStationIndexInOrdered; i++) { - const orderedStation = orderedRouteStations[i]; - if (orderedStation) { - const stationIndexInData = stationData.findIndex( - (station: any) => - String(station.id) === String(orderedStation.id), - ); - if (stationIndexInData >= 0) { - passedStations.push( - stationPoints[stationIndexInData * 2] as number, - stationPoints[stationIndexInData * 2 + 1] as number, - ); - } - } + if (tramSegIndex >= 0 && orderedRouteStations && stationData && orderedStationSegs.length > 0) { + const passedPts1: number[] = []; + const unpassedPts1: number[] = []; + for (let i = 0; i < orderedRouteStations.length; i++) { + const orderedStation = (orderedRouteStations as any[])[i]; + const stationSeg = orderedStationSegs[i] ?? -1; + if (!orderedStation || stationSeg < 0) continue; + const isPassed = simulationDirection === 1 ? stationSeg < tramSegIndex : stationSeg > tramSegIndex; + const stIdx = stationData.findIndex((s: any) => String(s.id) === String(orderedStation.id)); + if (stIdx < 0) continue; + const sx = stationPoints[stIdx * 2] as number; + const sy = stationPoints[stIdx * 2 + 1] as number; + if (isPassed) { passedPts1.push(sx, sy); } else { unpassedPts1.push(sx, sy); } } - 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); + if (passedPts1.length > 0) { + gl.uniform4f(u_color_pts, ((PATH_COLOR >> 16) & 0xff) / 255, ((PATH_COLOR >> 8) & 0xff) / 255, (PATH_COLOR & 0xff) / 255, 1); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(passedPts1), gl.STATIC_DRAW); + gl.drawArrays(gl.POINTS, 0, passedPts1.length / 2); } - } - - if ( - currentStationIndexInOrdered >= 0 && - orderedRouteStations && - stationData - ) { - const unpassedStations: number[] = []; - - for ( - let i = currentStationIndexInOrdered + 1; - i < orderedRouteStations.length; - i++ - ) { - const orderedStation = orderedRouteStations[i]; - if (orderedStation) { - const stationIndexInData = stationData.findIndex( - (station: any) => - String(station.id) === String(orderedStation.id), - ); - if (stationIndexInData >= 0) { - unpassedStations.push( - stationPoints[stationIndexInData * 2] as number, - stationPoints[stationIndexInData * 2 + 1] as number, - ); - } - } - } - 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); + if (unpassedPts1.length > 0) { + gl.uniform4f(u_color_pts, ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, (UNPASSED_STATION_COLOR & 0xff) / 255, 1); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(unpassedPts1), gl.STATIC_DRAW); + gl.drawArrays(gl.POINTS, 0, unpassedPts1.length / 2); } } else { - 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.uniform4f(u_color_pts, ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, (UNPASSED_STATION_COLOR & 0xff) / 255, 1); gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW); gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2); } @@ -1501,37 +1511,21 @@ export const WebGLMap = observer(() => { } } - let currentStationIndexInOrdered = -1; - if (currentStationId && orderedRouteStations) { - currentStationIndexInOrdered = orderedRouteStations.findIndex( - (station: any) => String(station.id) === String(currentStationId), - ); - } - const passedStationIds = new Set(); const unpassedStationIds = new Set(); - if (currentStationIndexInOrdered >= 0 && orderedRouteStations) { - for (let i = 0; i < currentStationIndexInOrdered; i++) { - const station = orderedRouteStations[i]; - if (station) { - passedStationIds.add(String(station.id)); - } - } - - for ( - let i = currentStationIndexInOrdered; - i < orderedRouteStations.length; - i++ - ) { - const station = orderedRouteStations[i]; - if (station) { - unpassedStationIds.add(String(station.id)); - } + if (tramSegIndex >= 0 && orderedRouteStations && orderedStationSegs.length === orderedRouteStations.length) { + for (let i = 0; i < orderedRouteStations.length; i++) { + const station = (orderedRouteStations as any[])[i]; + const seg = orderedStationSegs[i] ?? -1; + if (!station || seg < 0) continue; + const isPassed = simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex; + if (isPassed) passedStationIds.add(String(station.id)); + else unpassedStationIds.add(String(station.id)); } } else { if (orderedRouteStations) { - orderedRouteStations.forEach((station: any) => { + (orderedRouteStations as any[]).forEach((station) => { unpassedStationIds.add(String(station.id)); }); } @@ -1668,12 +1662,12 @@ export const WebGLMap = observer(() => { const cos = Math.cos(rotationAngle); const sin = Math.sin(rotationAngle); - const startStationData = stationData.find( - (station: any) => station.id.toString() === apiStore.context?.startStopId, - ); - const endStationData = stationData.find( - (station: any) => station.id.toString() === apiStore.context?.endStopId, - ); + const startStationData = orderedRouteStations?.[0] + ? stationData.find((station: any) => station.id.toString() === String(orderedRouteStations[0].id)) + : stationData.find((station: any) => station.id.toString() === apiStore.context?.startStopId); + const endStationData = orderedRouteStations?.length + ? stationData.find((station: any) => station.id.toString() === String(orderedRouteStations[orderedRouteStations.length - 1].id)) + : stationData.find((station: any) => station.id.toString() === apiStore.context?.endStopId); const terminalStations: number[] = []; @@ -1773,7 +1767,7 @@ export const WebGLMap = observer(() => { } return best; })(); - return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex; + return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex); })() : false; @@ -1806,7 +1800,7 @@ export const WebGLMap = observer(() => { } return best; })(); - return tramSegIndex !== -1 && seg !== -1 && seg < tramSegIndex; + return tramSegIndex !== -1 && seg !== -1 && (simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex); })() : false; @@ -1832,20 +1826,11 @@ export const WebGLMap = observer(() => { const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255; if (startStationData && endStationData) { - gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1.0); + const startIsPassed = simulationDirection === 1 ? true : isStartPassed; + const endIsPassed = simulationDirection === -1 ? true : isEndPassed; + gl.uniform4f(u_color_pts, startIsPassed ? r_passed : r_unpassed, startIsPassed ? g_passed : g_unpassed, startIsPassed ? b_passed : b_unpassed, 1.0); gl.drawArrays(gl.POINTS, 0, 1); - - if (isEndPassed) { - gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1.0); - } else { - gl.uniform4f( - u_color_pts, - r_unpassed, - g_unpassed, - b_unpassed, - 1.0, - ); - } + gl.uniform4f(u_color_pts, endIsPassed ? r_passed : r_unpassed, endIsPassed ? g_passed : g_unpassed, endIsPassed ? b_passed : b_unpassed, 1.0); gl.drawArrays(gl.POINTS, 1, 1); } else { const isStartStation = startStationData !== undefined; @@ -1885,6 +1870,8 @@ export const WebGLMap = observer(() => { nearestStationId, currentStationId, orderedRouteStations, + orderedStationSegs, + apiStore.simulationDirection, ]); useEffect(() => { @@ -2328,7 +2315,7 @@ export const WebGLMap = observer(() => { fontSize: primaryFontSize, textShadow: "0 0 4px rgba(0,0,0,0.6)", pointerEvents: "none", - whiteSpace: "nowrap", + whiteSpace: "pre-line", }} > {l.name} @@ -2344,7 +2331,7 @@ export const WebGLMap = observer(() => { lineHeight: secondaryLineHeight, color: "#CBCBCB", textShadow: "0 0 3px rgba(0,0,0,0.4)", - whiteSpace: "nowrap", + whiteSpace: "pre-line", ...secondaryPositionStyle, pointerEvents: "none", }} diff --git a/src/pages/Article/ArticleListPage/index.tsx b/src/pages/Article/ArticleListPage/index.tsx index 60b1510..559c86b 100644 --- a/src/pages/Article/ArticleListPage/index.tsx +++ b/src/pages/Article/ArticleListPage/index.tsx @@ -1,6 +1,6 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; -import { authStore, articlesStore, languageStore, SearchInput } from "@shared"; +import { authStore, articlesStore, languageStore, SearchInput, selectedCityStore } from "@shared"; import { useEffect, useState, useMemo } from "react"; import { observer } from "mobx-react-lite"; import { Trash2, Eye, Minus } from "lucide-react"; @@ -75,14 +75,16 @@ export const ArticleListPage = observer(() => { const rows = useMemo(() => { const query = searchQuery.trim().toLowerCase(); + const cityId = selectedCityStore.selectedCityId; return articleList[language].data .filter((article) => !query || (article.heading ?? "").toLowerCase().includes(query)) + .filter((article) => !cityId || article.city_id === cityId) .map((article) => ({ id: article.id, heading: article.heading, body: article.body, })); - }, [articleList[language].data, searchQuery]); + }, [articleList[language].data, searchQuery, selectedCityStore.selectedCityId]); return ( <> diff --git a/src/pages/City/CityCreatePage/index.tsx b/src/pages/City/CityCreatePage/index.tsx index 7040f3e..9ea5b78 100644 --- a/src/pages/City/CityCreatePage/index.tsx +++ b/src/pages/City/CityCreatePage/index.tsx @@ -28,7 +28,8 @@ import { LanguageSwitcher, ImageUploadCard } from "@widgets"; export const CityCreatePage = observer(() => { const navigate = useNavigate(); const { language } = languageStore; - const { createCityData, setCreateCityData, setCreateCityWeatherCode } = cityStore; + const { createCityData, setCreateCityData, setCreateCityWeatherCode } = + cityStore; const [isLoading, setIsLoading] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); @@ -72,7 +73,7 @@ export const CityCreatePage = observer(() => { createCityData[language].name, createCityData.country_code, media.id, - language + language, ); }; @@ -111,7 +112,7 @@ export const CityCreatePage = observer(() => { e.target.value, createCityData.country_code, createCityData.arms, - language + language, ) } /> @@ -127,7 +128,7 @@ export const CityCreatePage = observer(() => { createCityData[language].name, e.target.value, createCityData.arms, - language + language, ); }} > @@ -144,7 +145,6 @@ export const CityCreatePage = observer(() => { label="Код города для погоды" type="number" value={createCityData.weather_city_code ?? 0} - helperText="Числовой код города в источнике погоды (Кранштат)" onChange={(e) => setCreateCityWeatherCode(Number(e.target.value))} /> @@ -162,7 +162,7 @@ export const CityCreatePage = observer(() => { createCityData[language].name, createCityData.country_code, "", - language + language, ); setActiveMenuType(null); }} diff --git a/src/pages/City/CityEditPage/index.tsx b/src/pages/City/CityEditPage/index.tsx index dab97bc..8759211 100644 --- a/src/pages/City/CityEditPage/index.tsx +++ b/src/pages/City/CityEditPage/index.tsx @@ -40,7 +40,13 @@ export const CityEditPage = observer(() => { >(null); const { language } = languageStore; const { id } = useParams(); - const { editCityData, editCity, getCity, setEditCityData, setEditCityWeatherCode } = cityStore; + const { + editCityData, + editCity, + getCity, + setEditCityData, + setEditCityWeatherCode, + } = cityStore; const { getCountries } = countryStore; const { getMedia, getOneMedia } = mediaStore; @@ -108,7 +114,7 @@ export const CityEditPage = observer(() => { : null; const effectiveArmsUrl = isMediaIdEmpty(editCityData.arms) ? null - : selectedMedia?.id ?? editCityData.arms; + : (selectedMedia?.id ?? editCityData.arms); if (isLoadingData) { return ( @@ -185,7 +191,6 @@ export const CityEditPage = observer(() => { label="Код города для погоды" type="number" value={editCityData.weather_city_code ?? 0} - helperText="Числовой код города в источнике погоды (Кранштат)" onChange={(e) => setEditCityWeatherCode(Number(e.target.value))} /> diff --git a/src/pages/CreateSightPage/index.tsx b/src/pages/CreateSightPage/index.tsx index cd611de..f0e34da 100644 --- a/src/pages/CreateSightPage/index.tsx +++ b/src/pages/CreateSightPage/index.tsx @@ -12,7 +12,7 @@ import { CreateRightTab, LeaveAgree, } from "@widgets"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; function a11yProps(index: number) { diff --git a/src/pages/Media/MediaCreatePage/index.tsx b/src/pages/Media/MediaCreatePage/index.tsx index c283a4a..d5e0bc7 100644 --- a/src/pages/Media/MediaCreatePage/index.tsx +++ b/src/pages/Media/MediaCreatePage/index.tsx @@ -7,7 +7,7 @@ import { FormControl, InputLabel, } from "@mui/material"; -import { mediaStore, MEDIA_TYPE_LABELS } from "@shared"; +import { mediaStore, MEDIA_TYPE_LABELS, selectedCityStore } from "@shared"; import { observer } from "mobx-react-lite"; import { ArrowLeft, Loader2, Save } from "lucide-react"; import { useState } from "react"; @@ -23,7 +23,7 @@ export const MediaCreatePage = observer(() => { const handleCreate = async () => { try { setIsLoading(true); - await mediaStore.createMedia(name, type); + await mediaStore.createMedia(name, type, selectedCityStore.selectedCityId); toast.success("Медиа успешно создано"); navigate("/media"); } catch (error) { diff --git a/src/pages/Media/MediaListPage/index.tsx b/src/pages/Media/MediaListPage/index.tsx index df2c99f..91980ca 100644 --- a/src/pages/Media/MediaListPage/index.tsx +++ b/src/pages/Media/MediaListPage/index.tsx @@ -1,6 +1,6 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; -import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore, SearchInput } from "@shared"; +import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore, SearchInput, selectedCityStore } from "@shared"; import { useEffect, useState, useMemo } from "react"; import { observer } from "mobx-react-lite"; import { Eye, Trash2, Minus } from "lucide-react"; @@ -98,14 +98,16 @@ export const MediaListPage = observer(() => { const rows = useMemo(() => { const query = searchQuery.trim().toLowerCase(); + const cityId = selectedCityStore.selectedCityId; return media .filter((item) => !query || (item.media_name ?? "").toLowerCase().includes(query)) + .filter((item) => !cityId || item.city_id === cityId) .map((item) => ({ id: item.id, media_name: item.media_name, media_type: item.media_type, })); - }, [media, searchQuery]); + }, [media, searchQuery, selectedCityStore.selectedCityId]); return ( <> diff --git a/src/pages/Route/RouteCreatePage/index.tsx b/src/pages/Route/RouteCreatePage/index.tsx index 975957f..908f4de 100644 --- a/src/pages/Route/RouteCreatePage/index.tsx +++ b/src/pages/Route/RouteCreatePage/index.tsx @@ -286,9 +286,9 @@ export const RouteCreatePage = observer(() => { newRoute.governor_appeal = governor_appeal; } - await routeStore.createRoute(newRoute); + const newId = await routeStore.createRoute(newRoute); toast.success("Маршрут успешно создан"); - navigate(-1); + navigate(`/route/${newId}/edit`); } catch (error) { console.error(error); toast.error("Произошла ошибка при создании маршрута"); diff --git a/src/pages/Route/route-preview/RightSidebar.tsx b/src/pages/Route/route-preview/RightSidebar.tsx index d13924f..25114dc 100644 --- a/src/pages/Route/route-preview/RightSidebar.tsx +++ b/src/pages/Route/route-preview/RightSidebar.tsx @@ -141,6 +141,7 @@ export function RightSidebar() { bgcolor="primary.main" border="1px solid #e0e0e0" borderRadius={2} + zIndex={2} > Настройка маршрута diff --git a/src/pages/Route/route-preview/Widgets.tsx b/src/pages/Route/route-preview/Widgets.tsx index 1b8d5d0..a6bb046 100644 --- a/src/pages/Route/route-preview/Widgets.tsx +++ b/src/pages/Route/route-preview/Widgets.tsx @@ -2,6 +2,7 @@ import { Stack, Typography, Box, IconButton } from "@mui/material"; import { Close } from "@mui/icons-material"; import { Landmark } from "lucide-react"; import { useMapData } from "./MapDataContext"; +import { RouteWidget } from "./webgl-prototype/RouteWidget"; export function Widgets() { const { selectedSight, setSelectedSight } = useMapData(); @@ -13,22 +14,11 @@ export function Widgets() { position="absolute" top={32} left={32} + zIndex={2} sx={{ pointerEvents: "none" }} > - - - Остановка - - + {/* Виджет маршрута */} + {/* Виджет выбранной достопримечательности (заменяет виджет погоды) */} + (text?.length ?? 0) > maxLength; + +const getLabelSizeClass = (text: string | undefined) => { + const length = text?.length ?? 0; + if (length <= 40) return ""; + if (length <= 60) return styles["route-widget-label--medium"]; + if (length <= 80) return styles["route-widget-label--small"]; + return styles["route-widget-label--xsmall"]; +}; + +export const RouteWidget = observer(() => { + const { routeData, stationData } = useMapData(); + const { language } = languageStore; + + const stations = stationData?.[language] ?? stationData?.["ru"] ?? []; + const stationsRu = stationData?.["ru"] ?? []; + + const startStation = stations[0]; + const endStation = stations[stations.length - 1]; + + const startStationRu = stationsRu[0]; + const endStationRu = stationsRu[stationsRu.length - 1]; + + const enSubtitle = `${startStationRu?.name ?? ""} - ${endStationRu?.name ?? ""}`; + const zhSubtitle = `${startStation?.name ?? ""} - ${endStation?.name ?? ""}`; + const subtitle = language === "zh" ? zhSubtitle : enSubtitle; + + return ( +
+
+ {routeData?.route_sys_number || ""} +
+
+
+ {startStation?.name} +
+
+ {endStation?.name} +
+
+ {subtitle} +
+
+
+ ); +}); diff --git a/src/pages/Station/StationCreatePage/index.tsx b/src/pages/Station/StationCreatePage/index.tsx index 2f44735..8a9732f 100644 --- a/src/pages/Station/StationCreatePage/index.tsx +++ b/src/pages/Station/StationCreatePage/index.tsx @@ -68,9 +68,9 @@ export const StationCreatePage = observer(() => { const executeCreate = async () => { try { setIsLoading(true); - await createStation(); + const data = await createStation(); toast.success("Остановка успешно создана"); - navigate("/station"); + navigate(`/station/${data.id}/edit`); } catch (error) { console.error("Error creating station:", error); toast.error("Ошибка при создании остановки"); diff --git a/src/pages/User/UserEditPage/index.tsx b/src/pages/User/UserEditPage/index.tsx index c7820c0..5a24082 100644 --- a/src/pages/User/UserEditPage/index.tsx +++ b/src/pages/User/UserEditPage/index.tsx @@ -326,6 +326,9 @@ export const UserEditPage = observer(() => { if (!next.includes("snapshot_create")) { next.push("snapshot_create"); } + if (!next.includes("devices_maintenance_rw")) { + next.push("devices_maintenance_rw"); + } next.push("admin"); return next; }); @@ -347,7 +350,7 @@ export const UserEditPage = observer(() => { Чтение Чтение/Запись - Создание (snapshot_create) + Доп. права @@ -386,6 +389,8 @@ export const UserEditPage = observer(() => { }); }; + const isDevicesResource = key === "devices"; + const handleSnapshotCreateChange = (checked: boolean) => { if (!isSnapshotResource) { return; @@ -400,6 +405,13 @@ export const UserEditPage = observer(() => { }); }; + const handleMaintenanceChange = (checked: boolean) => { + setLocalRoles((prev) => { + const without = prev.filter((r) => r !== "devices_maintenance_rw"); + return checked ? [...without, "devices_maintenance_rw"] : without; + }); + }; + return ( {label} @@ -447,6 +459,14 @@ export const UserEditPage = observer(() => { handleSnapshotCreateChange(e.target.checked) } size="small" + title="Разрешает создавать новые снапшоты" + /> + ) : isDevicesResource ? ( + handleMaintenanceChange(e.target.checked)} + size="small" + title="Разрешает переводить устройства в режим технического обслуживания" /> ) : ( diff --git a/src/shared/modals/ArticleSelectOrCreateDialog/index.tsx b/src/shared/modals/ArticleSelectOrCreateDialog/index.tsx index e1eee1f..fd39cb1 100644 --- a/src/shared/modals/ArticleSelectOrCreateDialog/index.tsx +++ b/src/shared/modals/ArticleSelectOrCreateDialog/index.tsx @@ -5,6 +5,7 @@ import { SelectMediaDialog, UploadMediaDialog, Language, + selectedCityStore, } from "@shared"; import { observer } from "mobx-react-lite"; import { useEffect, useState } from "react"; @@ -232,6 +233,7 @@ export const ArticleSelectOrCreateDialog = observer( return; } + const cityId = selectedCityStore.selectedCityId; const response = await authInstance.post("/article", { translations: { heading: { @@ -245,6 +247,7 @@ export const ArticleSelectOrCreateDialog = observer( zh: newArticleData.zh.body || "Новый текст (ZH)", }, }, + ...(cityId ? { city_id: cityId } : {}), }); const { id } = response.data; @@ -519,9 +522,12 @@ export const ArticleSelectOrCreateDialog = observer( languageStore.setLanguage("ru"); }; - const filteredArticles = articles[modalLanguage].filter((article) => - article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase()) - ); + const cityId = selectedCityStore.selectedCityId; + const filteredArticles = articles[modalLanguage].filter((article) => { + if (!article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase())) return false; + if (cityId && article.city_id !== cityId) return false; + return true; + }); const [isPreviewLoading, setIsPreviewLoading] = useState(false); const [queuedPreviewId, setQueuedPreviewId] = useState(null); diff --git a/src/shared/modals/SelectArticleDialog/index.tsx b/src/shared/modals/SelectArticleDialog/index.tsx index c4d7f5c..c47412e 100644 --- a/src/shared/modals/SelectArticleDialog/index.tsx +++ b/src/shared/modals/SelectArticleDialog/index.tsx @@ -1,4 +1,4 @@ -import { articlesStore, authInstance, languageStore } from "@shared"; +import { articlesStore, authInstance, languageStore, selectedCityStore } from "@shared"; import { observer } from "mobx-react-lite"; import { useEffect, useState } from "react"; import { @@ -116,12 +116,16 @@ export const SelectArticleModal = observer( } }; + const cityId = selectedCityStore.selectedCityId; + const filteredArticles = articles[languageStore.language].filter( - (article) => !linkedArticleIds.includes(article.id) + (article) => { + if (linkedArticleIds.includes(article.id)) return false; + if (searchQuery && !article.service_name?.toLowerCase().includes(searchQuery.toLowerCase())) return false; + if (cityId && article.city_id !== cityId) return false; + return true; + } ); - // .filter((article) => - // article.service_name.toLowerCase().includes(searchQuery.toLowerCase()) - // ); return ( !linkedMediaIds.includes(mediaItem.id)) // Use mediaItem to avoid name collision + .filter((mediaItem) => !linkedMediaIds.includes(mediaItem.id)) .filter((mediaItem) => mediaItem.media_name.toLowerCase().includes(searchQuery.toLowerCase()) - ); + ) + .filter((mediaItem) => { + if (!cityId) return true; + return mediaItem.city_id === cityId; + }); if (mediaType) { filteredMedia = filteredMedia.filter( diff --git a/src/shared/modals/UploadMediaDialog/index.tsx b/src/shared/modals/UploadMediaDialog/index.tsx index 0cfc879..53d2ec8 100644 --- a/src/shared/modals/UploadMediaDialog/index.tsx +++ b/src/shared/modals/UploadMediaDialog/index.tsx @@ -4,9 +4,12 @@ import { editSightStore, generateDefaultMediaName, clearBlobAndGLTFCache, + authStore, + snapshotStore, } from "@shared"; import { observer } from "mobx-react-lite"; import { useEffect, useState, useRef } from "react"; +import { toast } from "react-toastify"; import { Dialog, DialogTitle, @@ -247,12 +250,16 @@ export const UploadMediaDialog = observer( setIsLoading(true); setError(null); + const uploadStartTime = Date.now(); + try { + const effectiveMediaType = hardcodeType + ? (MEDIA_TYPE_VALUES[hardcodeType] as number) + : mediaType; + const media = await uploadMedia( mediaFilename, - hardcodeType - ? (MEDIA_TYPE_VALUES[hardcodeType] as number) - : mediaType, + effectiveMediaType, mediaFile, mediaName ); @@ -263,6 +270,40 @@ export const UploadMediaDialog = observer( await afterUpload(media); } } + + if (effectiveMediaType === 2) { + const uploadDurationSec = Math.round((Date.now() - uploadStartTime) / 1000); + const minutes = Math.floor(uploadDurationSec / 60); + const seconds = uploadDurationSec % 60; + const durationStr = minutes > 0 + ? `${minutes} мин ${seconds} сек` + : `${seconds} сек`; + + const fileSizeMb = mediaFile.size / (1024 * 1024); + const fileSizeStr = fileSizeMb >= 1024 + ? `${(fileSizeMb / 1024).toFixed(2)} ГБ` + : `${fileSizeMb.toFixed(1)} МБ`; + + if (authStore.canRead("snapshots")) { + try { + await snapshotStore.getStorageInfo(); + const storage = snapshotStore.storageInfo; + if (storage) { + toast.success( + `Видео (${fileSizeStr}) загружено за ${durationStr}. Свободно на диске: ${storage.available_disk_space_gb.toFixed(2)} ГБ из ${storage.total_disk_space_gb.toFixed(2)} ГБ`, + { autoClose: 8000 } + ); + } else { + toast.success(`Видео (${fileSizeStr}) загружено за ${durationStr}`, { autoClose: 6000 }); + } + } catch { + toast.success(`Видео (${fileSizeStr}) загружено за ${durationStr}`, { autoClose: 6000 }); + } + } else { + toast.success(`Видео (${fileSizeStr}) загружено за ${durationStr}`, { autoClose: 6000 }); + } + } + setSuccess(true); setTimeout(() => { diff --git a/src/shared/store/ArticlesStore/index.tsx b/src/shared/store/ArticlesStore/index.tsx index c5a69d3..bc4f1b5 100644 --- a/src/shared/store/ArticlesStore/index.tsx +++ b/src/shared/store/ArticlesStore/index.tsx @@ -12,6 +12,7 @@ export type Article = { heading: string; body: string; service_name: string; + city_id?: number | null; ru?: { heading: string; body: string; diff --git a/src/shared/store/CreateSightStore/index.tsx b/src/shared/store/CreateSightStore/index.tsx index a2a7200..144c3c5 100644 --- a/src/shared/store/CreateSightStore/index.tsx +++ b/src/shared/store/CreateSightStore/index.tsx @@ -4,6 +4,7 @@ import { authInstance, languageInstance, mediaStore, + selectedCityStore, } from "@shared"; import { makeAutoObservable, runInAction } from "mobx"; @@ -129,6 +130,7 @@ class CreateSightStore { zh: articleZhData.body, }, }, + ...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}), }); const { id } = articleRes.data; @@ -346,6 +348,7 @@ class CreateSightStore { const response = await languageInstance("ru").post("/article", { heading: hasAnyName ? ruName : "", body: "", + ...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}), }); const newLeftArticleId = response.data.id; @@ -449,6 +452,7 @@ class CreateSightStore { const res = await languageInstance("ru").post("/article", { heading: this.sight.ru.left.heading, body: this.sight.ru.left.body, + ...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}), }); finalLeftArticleId = res.data.id; await languageInstance("en").patch(`/article/${finalLeftArticleId}`, { @@ -567,6 +571,9 @@ class CreateSightStore { formData.append("filename", filename); if (media_name) formData.append("media_name", media_name); formData.append("type", type.toString()); + if (selectedCityStore.selectedCityId) { + formData.append("city_id", selectedCityStore.selectedCityId.toString()); + } try { const response = await authInstance.post(`/media`, formData); diff --git a/src/shared/store/EditSightStore/index.tsx b/src/shared/store/EditSightStore/index.tsx index b7d0999..e0e1eaa 100644 --- a/src/shared/store/EditSightStore/index.tsx +++ b/src/shared/store/EditSightStore/index.tsx @@ -4,6 +4,7 @@ import { Language, languageInstance, mediaStore, + selectedCityStore, } from "@shared"; import { makeAutoObservable, runInAction } from "mobx"; @@ -270,6 +271,7 @@ class EditSightStore { const response = await languageInstance("ru").post(`/article`, { heading: this.sight.ru.left.heading, body: this.sight.ru.left.body, + ...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}), }); createdLeftArticleId = response.data.id; await languageInstance("en").patch(`/article/${createdLeftArticleId}`, { @@ -412,6 +414,7 @@ class EditSightStore { const response = await languageInstance("ru").post(`/article`, { heading: hasAnyName ? ruName : "", body: "", + ...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}), }); this.sight.common.left_article = response.data.id; @@ -510,6 +513,9 @@ class EditSightStore { formData.append("media_name", media_name); } formData.append("type", type.toString()); + if (selectedCityStore.selectedCityId) { + formData.append("city_id", selectedCityStore.selectedCityId.toString()); + } const response = await authInstance.post(`/media`, formData); this.fileToUpload = null; @@ -652,6 +658,7 @@ class EditSightStore { zh: articleZhData.body, }, }, + ...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}), }); const { id } = articleId.data; diff --git a/src/shared/store/MediaStore/index.tsx b/src/shared/store/MediaStore/index.tsx index bbbb3bb..448d44d 100644 --- a/src/shared/store/MediaStore/index.tsx +++ b/src/shared/store/MediaStore/index.tsx @@ -6,6 +6,7 @@ export type Media = { filename: string; media_name: string; media_type: number; + city_id?: number | null; }; class MediaStore { @@ -75,10 +76,11 @@ class MediaStore { return response.data; }; - createMedia = async (name: string, type: string) => { + createMedia = async (name: string, type: string, cityId?: number | null) => { const response = await authInstance.post("/media", { media_name: name, media_type: type, + ...(cityId ? { city_id: cityId } : {}), }); runInAction(() => { this.media.push(response.data); diff --git a/src/shared/store/RouteStore/index.ts b/src/shared/store/RouteStore/index.ts index 165a505..7825706 100644 --- a/src/shared/store/RouteStore/index.ts +++ b/src/shared/store/RouteStore/index.ts @@ -53,7 +53,7 @@ class RouteStore { }); }; - createRoute = async (route: any) => { + createRoute = async (route: any): Promise => { const response = await authInstance.post("/route", route); const id = response.data.id; @@ -61,6 +61,8 @@ class RouteStore { this.route[id] = { ...route, id }; this.routes.data = [...this.routes.data, { ...route, id }]; }); + + return id; }; deleteRoute = async (id: number) => { diff --git a/src/shared/store/StationsStore/index.ts b/src/shared/store/StationsStore/index.ts index 2b33057..f0f171d 100644 --- a/src/shared/store/StationsStore/index.ts +++ b/src/shared/store/StationsStore/index.ts @@ -468,7 +468,7 @@ class StationsStore { this.stationLists[language].data.push(response.data); }); - const stationId = response.data.id; + const stationId: number = response.data.id; for (const lang of ["ru", "en", "zh"].filter( (lang) => lang !== language diff --git a/src/widgets/DevicesTable/index.tsx b/src/widgets/DevicesTable/index.tsx index a7fb6c6..93bc46f 100644 --- a/src/widgets/DevicesTable/index.tsx +++ b/src/widgets/DevicesTable/index.tsx @@ -129,6 +129,8 @@ const transformToRows = (vehicles: Vehicle[]): RowData[] => { export const DevicesTable = observer(() => { const canWriteDevices = authStore.canWrite("devices"); + const canMaintenanceDevices = authStore.hasRole("devices_maintenance_rw"); + const isMaintenanceOnly = canMaintenanceDevices && !canWriteDevices; const { getDevices, setSelectedDevice, @@ -706,9 +708,24 @@ export const DevicesTable = observer(() => { demoConfirmSubmitting, routes, canWriteDevices, + isMaintenanceOnly, ], ); + const visibleColumns = useMemo(() => { + if (isMaintenanceOnly) { + return columns.filter((c) => + ["model", "tail_number", "maintenance_mode_on"].includes(c.field), + ); + } + if (!canWriteDevices) { + return columns.filter( + (c) => c.field !== "maintenance_mode_on" && c.field !== "demo_mode_enabled", + ); + } + return columns; + }, [columns, isMaintenanceOnly, canWriteDevices]); + useEffect(() => { const fetchData = async () => { setIsLoading(true); @@ -900,7 +917,7 @@ export const DevicesTable = observer(() => {