Files
WhiteNightsAdminPanel/src/pages/Route/route-preview/web-gl/web-gl-version.tsx
2025-11-06 00:58:10 +03:00

1726 lines
56 KiB
TypeScript

function initWebGLContext(
canvas: HTMLCanvasElement
): WebGLRenderingContext | null {
const gl =
(canvas.getContext("webgl") as WebGLRenderingContext | null) ||
(canvas.getContext("experimental-webgl") as WebGLRenderingContext | null);
return gl;
}
function resizeCanvasToDisplaySize(canvas: HTMLCanvasElement): boolean {
const dpr = Math.max(1, window.devicePixelRatio || 1);
const displayWidth = Math.floor(canvas.clientWidth * dpr);
const displayHeight = Math.floor(canvas.clientHeight * dpr);
if (canvas.width !== displayWidth || canvas.height !== displayHeight) {
canvas.width = displayWidth;
canvas.height = displayHeight;
return true;
}
return false;
}
export const WebGLMap = observer(() => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const glRef = useRef<WebGLRenderingContext | null>(null);
const programRef = useRef<WebGLProgram | null>(null);
const bufferRef = useRef<WebGLBuffer | null>(null);
const pointProgramRef = useRef<WebGLProgram | null>(null);
const pointBufferRef = useRef<WebGLBuffer | null>(null);
const screenLineProgramRef = useRef<WebGLProgram | null>(null);
const screenLineBufferRef = useRef<WebGLBuffer | null>(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();
const scaleLimitsRef = useRef({
min: null as number | null,
max: null as number | null,
});
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);
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);
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,
]);
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]);
useEffect(() => {
const interval = setInterval(() => {
const timeSinceActivity = Date.now() - userActivityTimestamp;
if (timeSinceActivity >= 5000 && !isAutoMode) {
setIsAutoMode(true);
}
}, 1000);
return () => clearInterval(interval);
}, [userActivityTimestamp, isAutoMode, setIsAutoMode]);
useEffect(() => {
if (cameraAnimationStore.isActivelyAnimating) {
return;
}
if (isAutoMode && transformedTramCoords && screenCenter) {
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,
]);
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,
]);
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,
]);
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;
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) {
const generateThickLine = (points: Float32Array, width: number) => {
const vertices: number[] = [];
const halfWidth = width / 2;
if (points.length < 4) return new Float32Array();
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];
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;
vertices.push(x1 + perpX, y1 + perpY);
vertices.push(x1 - perpX, y1 - perpY);
vertices.push(x2 + perpX, y2 + perpY);
vertices.push(x1 - perpX, y1 - perpY);
vertices.push(x2 - perpX, y2 - perpY);
vertices.push(x2 + perpX, y2 + perpY);
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;
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);
}
}
}
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);
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);
gl.uniform1f(u_pointSize, 8.0 * scale * 1.5);
if (tramSegIndex >= 0) {
const passedStations = [];
for (let i = 0; i < stationData.length; i++) {
if (i <= tramSegIndex) {
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);
}
}
if (tramSegIndex >= 0) {
const unpassedStations = [];
for (let i = 0; i < stationData.length; i++) {
if (i > tramSegIndex) {
unpassedStations.push(
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 {
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);
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);
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[] = [];
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);
}
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) {
const terminalStationData: any[] = [];
if (startStationData) terminalStationData.push(startStationData);
if (endStationData) terminalStationData.push(endStationData);
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;
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;
}
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);
gl.uniform1f(u_pointSize, 18.0 * scale);
if (startStationData && endStationData) {
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);
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);
} else {
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);
}
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);
gl.drawArrays(gl.POINTS, 0, terminalStations.length / 2);
}
}
}
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<number, { x: number; y: number }>();
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 },
};
}
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) => {
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();
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 (
<div
className="map-layer"
style={{ width: "100%", height: "100vh", position: "relative" }}
>
<canvas
ref={canvasRef}
style={{ width: "100%", height: "100%", display: "block", zIndex: 0 }}
/>
<div
style={{
position: "absolute",
inset: 0,
pointerEvents: "none",
zIndex: 9999,
}}
>
{stationLabels.map((l, idx) => (
<div
key={idx}
style={{
position: "absolute",
left: l.x,
top: l.y,
transform: "translate(0, -50%)",
color: "#fff",
fontFamily: "Roboto",
}}
>
<div style={{ fontWeight: 700, fontSize: 16 }}>{l.name}</div>
{l.sub ? (
<div
style={{
color: "#CBCBCB",
fontWeight: 400,
fontSize: 13,
marginTop: 2,
}}
>
{l.sub}
</div>
) : null}
</div>
))}
{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 (
<img
key={`sight-${s.id}`}
src={sightIcon}
alt=""
width={size}
height={size}
style={{
position: "absolute",
left: sx - size / 2,
top: sy - size / 2,
pointerEvents: "auto",
cursor: "pointer",
}}
onClick={handleSightClick}
/>
);
})}
{(() => {
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 (
<TramIconWebGL
x={screenX}
y={screenY}
optimalAngle={optimalAngle}
scale={scale}
/>
);
})()}
</div>
</div>
);
});
export default WebGLMap;