feat: update demo page + add city_id for media and articles
This commit is contained in:
@@ -56,7 +56,7 @@ class ApiStore {
|
||||
carrier: GetCarrierResponse | null = null;
|
||||
city: GetCityResponse | null = null;
|
||||
|
||||
private positionIndex = 0;
|
||||
positionIndex = 0;
|
||||
private positionInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
simulationSpeed = 1;
|
||||
|
||||
@@ -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<number>(-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<string>();
|
||||
const unpassedStationIds = new Set<string>();
|
||||
|
||||
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",
|
||||
}}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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))}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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("Произошла ошибка при создании маршрута");
|
||||
|
||||
@@ -141,6 +141,7 @@ export function RightSidebar() {
|
||||
bgcolor="primary.main"
|
||||
border="1px solid #e0e0e0"
|
||||
borderRadius={2}
|
||||
zIndex={2}
|
||||
>
|
||||
<Typography variant="h6" sx={{ mb: 2, color: "#fff" }} textAlign="center">
|
||||
Настройка маршрута
|
||||
|
||||
@@ -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" }}
|
||||
>
|
||||
<Stack
|
||||
bgcolor="primary.main"
|
||||
width={361}
|
||||
height={96}
|
||||
p={2}
|
||||
m={2}
|
||||
borderRadius={2}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
>
|
||||
<Typography variant="h6" sx={{ color: "#fff" }}>
|
||||
Остановка
|
||||
</Typography>
|
||||
</Stack>
|
||||
{/* Виджет маршрута */}
|
||||
<RouteWidget />
|
||||
|
||||
{/* Виджет выбранной достопримечательности (заменяет виджет погоды) */}
|
||||
<Stack
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
.route-widget-label.marquee {
|
||||
display: inline-block;
|
||||
animation: marquee 14s linear infinite;
|
||||
}
|
||||
|
||||
.route-widget-subtitle.marquee {
|
||||
display: inline-block;
|
||||
animation: marquee 14s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes marquee {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.route-widget {
|
||||
width: 361px;
|
||||
height: 96px;
|
||||
position: fixed;
|
||||
display: inline-flex;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.3) inset,
|
||||
/* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */
|
||||
padding: 1px; /* Чтобы контент не прилипал к рамке */
|
||||
background: linear-gradient(
|
||||
to bottom right,
|
||||
rgba(255, 255, 255, 0.2) 0%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
),
|
||||
rgba(179, 165, 152, 0.4);
|
||||
backdrop-filter: blur(10px);
|
||||
pointer-events: auto;
|
||||
z-index: 10000001;
|
||||
}
|
||||
|
||||
.route-widget-number {
|
||||
position: absolute;
|
||||
width: fit-content;
|
||||
left: 0px;
|
||||
top: 0px;
|
||||
min-width: 94px;
|
||||
max-width: 100px;
|
||||
height: 96px;
|
||||
background-color: #fcd500;
|
||||
color: black;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 70px;
|
||||
padding: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.route-widget-content {
|
||||
overflow: hidden;
|
||||
width: 257px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 13px;
|
||||
margin-left: 109px;
|
||||
margin-right: 9px;
|
||||
}
|
||||
|
||||
.route-widget-label {
|
||||
white-space: nowrap;
|
||||
font-size: 24px;
|
||||
margin: 1px 0;
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.route-widget-label--medium {
|
||||
font-size: 22px;
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.route-widget-label--small {
|
||||
font-size: 20px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.route-widget-label--xsmall {
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.route-widget-subtitle {
|
||||
white-space: nowrap;
|
||||
color: #cbcbcb;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import styles from "./RouteWidget.module.css";
|
||||
import { useMapData } from "../MapDataContext";
|
||||
import { languageStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
const shouldAnimate = (text: string | undefined, maxLength: number) =>
|
||||
(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 (
|
||||
<div className={styles["route-widget"]} style={{ position: "relative" }}>
|
||||
<div className={styles["route-widget-number"]}>
|
||||
{routeData?.route_sys_number || ""}
|
||||
</div>
|
||||
<div className={styles["route-widget-content"]}>
|
||||
<div
|
||||
className={[
|
||||
styles["route-widget-label"],
|
||||
shouldAnimate(startStation?.name, 18) ? styles["marquee"] : "",
|
||||
getLabelSizeClass(startStation?.name),
|
||||
].join(" ")}
|
||||
title={startStation?.name}
|
||||
>
|
||||
{startStation?.name}
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
styles["route-widget-label"],
|
||||
shouldAnimate(endStation?.name, 18) ? styles["marquee"] : "",
|
||||
getLabelSizeClass(endStation?.name),
|
||||
].join(" ")}
|
||||
title={endStation?.name}
|
||||
>
|
||||
{endStation?.name}
|
||||
</div>
|
||||
<div
|
||||
className={[
|
||||
styles["route-widget-subtitle"],
|
||||
shouldAnimate(subtitle, 50) ? styles["marquee"] : "",
|
||||
].join(" ")}
|
||||
title={subtitle}
|
||||
>
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -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("Ошибка при создании остановки");
|
||||
|
||||
@@ -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(() => {
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>Чтение/Запись</TableCell>
|
||||
<TableCell align="center" sx={{ fontWeight: 600 }}>
|
||||
Создание (snapshot_create)
|
||||
Доп. права
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
@@ -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 (
|
||||
<TableRow key={key} hover>
|
||||
<TableCell>{label}</TableCell>
|
||||
@@ -447,6 +459,14 @@ export const UserEditPage = observer(() => {
|
||||
handleSnapshotCreateChange(e.target.checked)
|
||||
}
|
||||
size="small"
|
||||
title="Разрешает создавать новые снапшоты"
|
||||
/>
|
||||
) : isDevicesResource ? (
|
||||
<Checkbox
|
||||
checked={localRoles.includes("devices_maintenance_rw")}
|
||||
onChange={(e) => handleMaintenanceChange(e.target.checked)}
|
||||
size="small"
|
||||
title="Разрешает переводить устройства в режим технического обслуживания"
|
||||
/>
|
||||
) : (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
|
||||
@@ -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 (
|
||||
<Dialog
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Media, mediaStore } from "@shared";
|
||||
import { Media, mediaStore, selectedCityStore } from "@shared";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
@@ -92,11 +92,17 @@ export const SelectMediaDialog = observer(
|
||||
};
|
||||
}, [hoveredMediaId, onSelectMedia, onClose]); // Dependencies for keyboard listener
|
||||
|
||||
const cityId = selectedCityStore.selectedCityId;
|
||||
|
||||
let filteredMedia = media
|
||||
.filter((mediaItem) => !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(
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -12,6 +12,7 @@ export type Article = {
|
||||
heading: string;
|
||||
body: string;
|
||||
service_name: string;
|
||||
city_id?: number | null;
|
||||
ru?: {
|
||||
heading: string;
|
||||
body: string;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -53,7 +53,7 @@ class RouteStore {
|
||||
});
|
||||
};
|
||||
|
||||
createRoute = async (route: any) => {
|
||||
createRoute = async (route: any): Promise<number> => {
|
||||
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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
<Box sx={{ p: 0 }}>
|
||||
<DataGrid
|
||||
rows={groupRows}
|
||||
columns={columns}
|
||||
columns={visibleColumns}
|
||||
checkboxSelection={canWriteDevices}
|
||||
disableRowSelectionExcludeModel
|
||||
disableRowSelectionOnClick
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user