tram icon, video-previews, resizing when inactive
All checks were successful
release-tag / release-image (push) Successful in 53s
All checks were successful
release-tag / release-image (push) Successful in 53s
This commit is contained in:
@ -28,6 +28,20 @@ import { Viewport } from "pixi-viewport";
|
|||||||
import { Text, TextStyle, FillGradient, Assets } from "pixi.js";
|
import { Text, TextStyle, FillGradient, Assets } from "pixi.js";
|
||||||
import { API_URL, GEO_URL } from "../config";
|
import { API_URL, GEO_URL } from "../config";
|
||||||
import sightIcon from "../icons/sight.svg";
|
import sightIcon from "../icons/sight.svg";
|
||||||
|
import tramRight from "../icons/tram-right.svg";
|
||||||
|
import tramLeft from "../icons/tram-left.svg";
|
||||||
|
import tramTopRight from "../icons/tram-top-right.svg";
|
||||||
|
import tramBottomRight from "../icons/tram-bottom-right.svg";
|
||||||
|
import tramTopLeft from "../icons/tram-top-left.svg";
|
||||||
|
import tramBottomLeft from "../icons/tram-bottom-left.svg";
|
||||||
|
const arrowTransforms = {
|
||||||
|
left: [-1, 0],
|
||||||
|
right: [1, 0],
|
||||||
|
"bottom-left": [1, 1],
|
||||||
|
"top-left": [1, -1],
|
||||||
|
"bottom-right": [-1, 1],
|
||||||
|
"top-right": [-1, -1],
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "MainMap",
|
name: "MainMap",
|
||||||
@ -38,6 +52,9 @@ export default {
|
|||||||
viewport: null,
|
viewport: null,
|
||||||
routeGraphics: null,
|
routeGraphics: null,
|
||||||
videoOverlayVisible: false,
|
videoOverlayVisible: false,
|
||||||
|
lastActivityTime: Date.now(),
|
||||||
|
inactivityInterval: null,
|
||||||
|
lastVideoPreviewId: null,
|
||||||
stationContainer: null,
|
stationContainer: null,
|
||||||
sightsContainer: null,
|
sightsContainer: null,
|
||||||
toCanvasPoint: null,
|
toCanvasPoint: null,
|
||||||
@ -45,15 +62,63 @@ export default {
|
|||||||
attemptCount: 0,
|
attemptCount: 0,
|
||||||
routeGroup: null,
|
routeGroup: null,
|
||||||
routeLatlngs: [],
|
routeLatlngs: [],
|
||||||
|
sights: [],
|
||||||
tramPollTimer: null,
|
tramPollTimer: null,
|
||||||
passedGraphics: null,
|
passedGraphics: null,
|
||||||
remainingGraphics: null,
|
remainingGraphics: null,
|
||||||
tramGraphic: null,
|
tramGraphic: null,
|
||||||
|
tramSprite: null,
|
||||||
|
tramContainer: null,
|
||||||
|
tramArrow: null,
|
||||||
|
currentTramDirection: "right",
|
||||||
|
manualTramDirection: null,
|
||||||
|
manualDirectionUntil: 0,
|
||||||
|
inactivityTimer: null,
|
||||||
|
chooseTramDirection(canvasPt) {
|
||||||
|
// canvasPt: [x, y] in PIXI coords
|
||||||
|
let bestDir = "right";
|
||||||
|
let bestScore = Infinity;
|
||||||
|
const candidates = arrowTransforms;
|
||||||
|
|
||||||
|
Object.entries(candidates).forEach(([dir, vec]) => {
|
||||||
|
const len = Math.hypot(vec[0], vec[1]);
|
||||||
|
const unit = [vec[0] / len, vec[1] / len];
|
||||||
|
let score = 0;
|
||||||
|
[30, 60, 90].forEach((d) => {
|
||||||
|
const probeX = canvasPt[0] + unit[0] * d;
|
||||||
|
const probeY = canvasPt[1] + unit[1] * d;
|
||||||
|
// penalize proximity to stations
|
||||||
|
this.stationContainer.children.forEach((node) => {
|
||||||
|
const dx = probeX - node.x;
|
||||||
|
const dy = probeY - node.y;
|
||||||
|
if (Math.hypot(dx, dy) < 25) score += 3;
|
||||||
|
});
|
||||||
|
// penalize proximity to sights
|
||||||
|
this.sightsContainer.children.forEach((node) => {
|
||||||
|
const dx = probeX - node.x;
|
||||||
|
const dy = probeY - node.y;
|
||||||
|
if (Math.hypot(dx, dy) < 25) score += 3;
|
||||||
|
});
|
||||||
|
// penalize proximity to route segments
|
||||||
|
const pts = this.routeLatlngs.map((pt) => this.toCanvasPoint(pt));
|
||||||
|
pts.forEach((rpt) => {
|
||||||
|
const dx = probeX - rpt.x;
|
||||||
|
const dy = probeY - rpt.y;
|
||||||
|
if (Math.hypot(dx, dy) < 20) score += 1;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (score < bestScore) {
|
||||||
|
bestScore = score;
|
||||||
|
bestDir = dir;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return bestDir;
|
||||||
|
},
|
||||||
routeRotate: 0,
|
routeRotate: 0,
|
||||||
routeCentered: false,
|
routeCentered: false,
|
||||||
viewportAdjusted: false, // авто‑фит сделан?
|
viewportAdjusted: false,
|
||||||
clampApplied: false, // границы перемещения выставлены?
|
clampApplied: false,
|
||||||
stationZoomed: false, // приблизили ли к станции?
|
stationZoomed: false,
|
||||||
centerLatitude: null,
|
centerLatitude: null,
|
||||||
centerLongitude: null,
|
centerLongitude: null,
|
||||||
};
|
};
|
||||||
@ -62,9 +127,93 @@ export default {
|
|||||||
await this.createPixiApp();
|
await this.createPixiApp();
|
||||||
await this.fetchContext();
|
await this.fetchContext();
|
||||||
this.startRoutePolling();
|
this.startRoutePolling();
|
||||||
|
this.startInactivityWatcher();
|
||||||
|
this.setupActivityListeners();
|
||||||
|
this.startInactivityCheck();
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
setupActivityListeners() {
|
||||||
|
["mousemove", "mousedown", "keydown", "touchstart", "scroll"].forEach(
|
||||||
|
(evt) => window.addEventListener(evt, this.resetInactivity)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
resetInactivity() {
|
||||||
|
this.lastActivityTime = Date.now();
|
||||||
|
if (this.videoOverlayVisible) this.hideVideoOverlay();
|
||||||
|
},
|
||||||
|
startInactivityCheck() {
|
||||||
|
if (this.inactivityInterval) return;
|
||||||
|
this.inactivityInterval = setInterval(() => {
|
||||||
|
if (Date.now() - this.lastActivityTime >= 120000) {
|
||||||
|
this.showNearestSightVideo();
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
},
|
||||||
|
stopInactivityCheck() {
|
||||||
|
if (this.inactivityInterval) {
|
||||||
|
clearInterval(this.inactivityInterval);
|
||||||
|
this.inactivityInterval = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async showNearestSightVideo() {
|
||||||
|
console.log(
|
||||||
|
"[videoOverlay] Attempting to show nearest sight video at",
|
||||||
|
new Date().toISOString()
|
||||||
|
);
|
||||||
|
if (!this.sights || !this.sights.length) {
|
||||||
|
console.log("[videoOverlay] No sights available to select video.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// select a sight with a video_preview, fallback to first
|
||||||
|
const sight = this.sights.find((s) => s.video_preview) || this.sights[0];
|
||||||
|
const mediaId = sight.video_preview;
|
||||||
|
console.log("[videoOverlay] Selected mediaId:", mediaId);
|
||||||
|
if (!mediaId) {
|
||||||
|
console.log("[videoOverlay] No mediaId found on selected sight.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.lastVideoPreviewId === mediaId) {
|
||||||
|
console.log(
|
||||||
|
"[videoOverlay] mediaId same as last. Showing existing overlay."
|
||||||
|
);
|
||||||
|
this.videoOverlayVisible = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const src = `${API_URL}/media/${mediaId}/download`;
|
||||||
|
this.replaceVideo(src, mediaId);
|
||||||
|
},
|
||||||
|
replaceVideo(src, previewId) {
|
||||||
|
console.log(
|
||||||
|
"[videoOverlay] replaceVideo called with src:",
|
||||||
|
src,
|
||||||
|
"previewId:",
|
||||||
|
previewId
|
||||||
|
);
|
||||||
|
const overlay = document.getElementById("video-overlay");
|
||||||
|
if (!overlay) return;
|
||||||
|
const vid = document.createElement("video");
|
||||||
|
vid.src = src;
|
||||||
|
vid.autoplay = true;
|
||||||
|
vid.muted = true;
|
||||||
|
vid.loop = true;
|
||||||
|
vid.playsInline = true;
|
||||||
|
vid.style.cssText = "width:100%;height:100%;object-fit:cover;";
|
||||||
|
vid.addEventListener("canplay", () => {
|
||||||
|
[...overlay.children].forEach((child) => {
|
||||||
|
if (child !== vid) overlay.removeChild(child);
|
||||||
|
});
|
||||||
|
this.videoOverlayVisible = true;
|
||||||
|
this.lastVideoPreviewId = previewId;
|
||||||
|
});
|
||||||
|
overlay.appendChild(vid);
|
||||||
|
},
|
||||||
|
hideVideoOverlay() {
|
||||||
|
this.videoOverlayVisible = false;
|
||||||
|
const overlay = document.getElementById("video-overlay");
|
||||||
|
if (overlay) overlay.innerHTML = "";
|
||||||
|
this.lastVideoPreviewId = null;
|
||||||
|
},
|
||||||
async createPixiApp() {
|
async createPixiApp() {
|
||||||
const container = document.getElementById("map");
|
const container = document.getElementById("map");
|
||||||
if (!container || this.pixiApp) return;
|
if (!container || this.pixiApp) return;
|
||||||
@ -80,6 +229,14 @@ export default {
|
|||||||
// add canvas to DOM (missing after last refactor)
|
// add canvas to DOM (missing after last refactor)
|
||||||
container.appendChild(this.pixiApp.canvas);
|
container.appendChild(this.pixiApp.canvas);
|
||||||
await Assets.load(sightIcon);
|
await Assets.load(sightIcon);
|
||||||
|
await Assets.load([
|
||||||
|
tramRight,
|
||||||
|
tramLeft,
|
||||||
|
tramTopRight,
|
||||||
|
tramBottomRight,
|
||||||
|
tramTopLeft,
|
||||||
|
tramBottomLeft,
|
||||||
|
]);
|
||||||
|
|
||||||
// гарантируем, что канвас всегда принимает pointer‑/touch‑события
|
// гарантируем, что канвас всегда принимает pointer‑/touch‑события
|
||||||
this.pixiApp.canvas.style.pointerEvents = "auto";
|
this.pixiApp.canvas.style.pointerEvents = "auto";
|
||||||
@ -381,28 +538,9 @@ export default {
|
|||||||
// гарантируем, что подписи горизонтальны
|
// гарантируем, что подписи горизонтальны
|
||||||
this.updateLabelOrientation();
|
this.updateLabelOrientation();
|
||||||
|
|
||||||
/* ─── Center the view on the first station (once, keep current zoom) ─── */
|
|
||||||
if (!this.stationZoomed && this.stationContainer.children.length) {
|
if (!this.stationZoomed && this.stationContainer.children.length) {
|
||||||
const firstNode = this.stationContainer.children[0];
|
this.autoFitViewport();
|
||||||
if (firstNode) {
|
this.stationZoomed = true;
|
||||||
// мировые координаты станции (учитывают поворот/масштаб routeGroup)
|
|
||||||
const b = firstNode.getBounds();
|
|
||||||
const worldPos = { x: b.x + b.width / 2, y: b.y + b.height / 2 };
|
|
||||||
|
|
||||||
// плавно перемещаем камеру к станции, масштаб НЕ меняем
|
|
||||||
this.viewport.animate({
|
|
||||||
time: 600,
|
|
||||||
position: { x: worldPos.x, y: worldPos.y },
|
|
||||||
easing: "easeInOutSine",
|
|
||||||
});
|
|
||||||
this.stationZoomed = true;
|
|
||||||
console.log(
|
|
||||||
`%c[centerToStation] world=(${worldPos.x.toFixed(
|
|
||||||
1
|
|
||||||
)},${worldPos.y.toFixed(1)})`,
|
|
||||||
"color: plum"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -447,49 +585,29 @@ export default {
|
|||||||
const sw = this.viewport.screenWidth;
|
const sw = this.viewport.screenWidth;
|
||||||
const sh = this.viewport.screenHeight;
|
const sh = this.viewport.screenHeight;
|
||||||
|
|
||||||
// Подбираем масштаб так, чтобы занять ~90% экрана
|
let scale = Math.min(sw / b.width, sh / b.height);
|
||||||
const fill = 0.9;
|
|
||||||
let scale = Math.min(sw / b.width, sh / b.height) * fill;
|
|
||||||
|
|
||||||
// Учитываем scale_min / scale_max, если есть
|
let target;
|
||||||
if (this.routeScaleMin !== null)
|
if (this.tramContainer) {
|
||||||
scale = Math.max(scale, this.routeScaleMin / 10);
|
// глобальная позиция контейнера с трамваем
|
||||||
if (this.routeScaleMax !== null)
|
const worldPos = this.tramContainer.getGlobalPosition();
|
||||||
scale = Math.min(scale, this.routeScaleMax / 10);
|
target = worldPos;
|
||||||
|
} else if (this.centerLatitude != null && this.centerLongitude != null) {
|
||||||
if (this.centerLatitude != null && this.centerLongitude != null) {
|
|
||||||
const local = this.toCanvasPoint([
|
const local = this.toCanvasPoint([
|
||||||
this.centerLatitude,
|
this.centerLatitude,
|
||||||
this.centerLongitude,
|
this.centerLongitude,
|
||||||
]);
|
]);
|
||||||
const global = this.routeGroup.toGlobal(
|
target = this.routeGroup.toGlobal(new PIXI.Point(local.x, local.y));
|
||||||
new PIXI.Point(local.x, local.y)
|
|
||||||
);
|
|
||||||
this.viewport.setZoom(scale, global.x, global.y);
|
|
||||||
} else {
|
} else {
|
||||||
const cx = b.x + b.width / 2;
|
target = new PIXI.Point(b.x + b.width / 2, b.y + b.height / 2);
|
||||||
const cy = b.y + b.height / 2;
|
|
||||||
this.viewport.setZoom(scale, cx, cy);
|
|
||||||
}
|
}
|
||||||
|
this.viewport.animate({
|
||||||
|
time: 600,
|
||||||
|
scale: scale,
|
||||||
|
position: { x: target.x, y: target.y },
|
||||||
|
easing: "easeInOutSine",
|
||||||
|
});
|
||||||
this.viewportAdjusted = true;
|
this.viewportAdjusted = true;
|
||||||
|
|
||||||
if (this.centerLatitude != null && this.centerLongitude != null) {
|
|
||||||
console.log(
|
|
||||||
`%c[autoFitViewport] scale=${scale.toFixed(2)}, center=(${
|
|
||||||
this.centerLatitude
|
|
||||||
},${this.centerLongitude})`,
|
|
||||||
"color: mediumspringgreen"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const cx = b.x + b.width / 2;
|
|
||||||
const cy = b.y + b.height / 2;
|
|
||||||
console.log(
|
|
||||||
`%c[autoFitViewport] scale=${scale.toFixed(2)}, center=(${cx.toFixed(
|
|
||||||
1
|
|
||||||
)},${cy.toFixed(1)})`,
|
|
||||||
"color: mediumspringgreen"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
applyPanClamp() {
|
applyPanClamp() {
|
||||||
return;
|
return;
|
||||||
@ -564,6 +682,7 @@ export default {
|
|||||||
),
|
),
|
||||||
fetch(`${API_URL}/route/${this.routeId}/sight`).then((r) => r.json()),
|
fetch(`${API_URL}/route/${this.routeId}/sight`).then((r) => r.json()),
|
||||||
]);
|
]);
|
||||||
|
this.sights = sights;
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`%cFetchRoute attempt #${this.attemptCount}`,
|
`%cFetchRoute attempt #${this.attemptCount}`,
|
||||||
@ -609,25 +728,10 @@ export default {
|
|||||||
this.drawSights(sights);
|
this.drawSights(sights);
|
||||||
// compensate rotation for sight icons
|
// compensate rotation for sight icons
|
||||||
this.updateLabelOrientation();
|
this.updateLabelOrientation();
|
||||||
// после линии, станций и (возможного) поворота автоматически подгоняем viewport
|
// сначала обновляем позицию трамвая, чтобы был this.tramContainer
|
||||||
this.autoFitViewport();
|
|
||||||
this.updateTramPosition();
|
this.updateTramPosition();
|
||||||
// center view on route-provided center after route load
|
// затем корректно центрируем и зумим вид
|
||||||
if (this.centerLatitude != null && this.centerLongitude != null) {
|
this.autoFitViewport();
|
||||||
const localCenter = this.toCanvasPoint([
|
|
||||||
this.centerLatitude,
|
|
||||||
this.centerLongitude,
|
|
||||||
]);
|
|
||||||
const globalCenter = this.routeGroup.toGlobal(
|
|
||||||
new PIXI.Point(localCenter.x, localCenter.y)
|
|
||||||
);
|
|
||||||
// animate viewport to this center (keep current zoom)
|
|
||||||
this.viewport.animate({
|
|
||||||
time: 600,
|
|
||||||
position: { x: globalCenter.x, y: globalCenter.y },
|
|
||||||
easing: "easeInOutSine",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
gotPath = true;
|
gotPath = true;
|
||||||
} else {
|
} else {
|
||||||
console.warn(
|
console.warn(
|
||||||
@ -646,6 +750,10 @@ export default {
|
|||||||
if (this.attemptCount > 1e6) this.attemptCount = 0;
|
if (this.attemptCount > 1e6) this.attemptCount = 0;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
setTramDirectionOverride(direction, durationMs) {
|
||||||
|
this.manualTramDirection = direction;
|
||||||
|
this.manualDirectionUntil = Date.now() + durationMs;
|
||||||
|
},
|
||||||
// ── Update tram position and color route based on progress ──
|
// ── Update tram position and color route based on progress ──
|
||||||
async updateTramPosition() {
|
async updateTramPosition() {
|
||||||
if (!this.routeLatlngs || !this.routeLatlngs.length) return;
|
if (!this.routeLatlngs || !this.routeLatlngs.length) return;
|
||||||
@ -694,28 +802,146 @@ export default {
|
|||||||
// draw or update tram marker
|
// draw or update tram marker
|
||||||
const current = this.routeLatlngs[passedCount];
|
const current = this.routeLatlngs[passedCount];
|
||||||
const { x, y } = this.toCanvasPoint(current);
|
const { x, y } = this.toCanvasPoint(current);
|
||||||
if (!this.tramGraphic) {
|
|
||||||
this.tramGraphic = new PIXI.Graphics();
|
let direction;
|
||||||
// set fill style to yellow
|
const now = Date.now();
|
||||||
this.tramGraphic.fill(0xfcd500);
|
if (this.manualTramDirection && this.manualDirectionUntil > now) {
|
||||||
// set stroke style for border
|
direction = this.manualTramDirection;
|
||||||
this.tramGraphic.setStrokeStyle({
|
} else {
|
||||||
width: 5,
|
direction = this.chooseTramDirection([x, y]);
|
||||||
color: 0x000000,
|
// clear expired override
|
||||||
alignment: 0.5,
|
if (this.manualDirectionUntil <= now) {
|
||||||
alpha: 1,
|
this.manualTramDirection = null;
|
||||||
});
|
}
|
||||||
// draw circle path
|
|
||||||
this.tramGraphic.beginPath();
|
|
||||||
this.tramGraphic.circle(0, 0, 14);
|
|
||||||
// fill the circle
|
|
||||||
this.tramGraphic.fill();
|
|
||||||
// draw the stroke
|
|
||||||
this.tramGraphic.stroke();
|
|
||||||
this.routeGroup.addChild(this.tramGraphic);
|
|
||||||
}
|
}
|
||||||
this.tramGraphic.x = x;
|
|
||||||
this.tramGraphic.y = y;
|
// this.setTramDirectionOverride("bottom-right", 60000);
|
||||||
|
|
||||||
|
const svgMap = {
|
||||||
|
right: tramRight,
|
||||||
|
left: tramLeft,
|
||||||
|
"top-right": tramTopRight,
|
||||||
|
"bottom-right": tramBottomRight,
|
||||||
|
"top-left": tramTopLeft,
|
||||||
|
"bottom-left": tramBottomLeft,
|
||||||
|
};
|
||||||
|
const projSrc = svgMap[direction];
|
||||||
|
|
||||||
|
// создаём контейнер с красным кружком и стрелкой, если ещё нет
|
||||||
|
if (!this.tramContainer) {
|
||||||
|
this.tramContainer = new PIXI.Container();
|
||||||
|
|
||||||
|
// 1) базовый кружок красного маркера
|
||||||
|
const circle = new PIXI.Graphics();
|
||||||
|
circle
|
||||||
|
.beginFill("#FCD500")
|
||||||
|
.lineStyle(6, "#000")
|
||||||
|
.drawCircle(0, 0, 14)
|
||||||
|
.endFill();
|
||||||
|
this.tramContainer.addChild(circle);
|
||||||
|
|
||||||
|
// 2) стрелка‑проекция
|
||||||
|
// Horizontal arrows flush to red circle, diagonal by anchor
|
||||||
|
const CIRCLE_D = 28; // diameter of the red circle (2*14)
|
||||||
|
const arrow = PIXI.Sprite.from(projSrc);
|
||||||
|
if (direction === "right") {
|
||||||
|
arrow.width = 112;
|
||||||
|
arrow.height = 82;
|
||||||
|
arrow.anchor.set(0, 0.5);
|
||||||
|
arrow.x = CIRCLE_D / 2 - 12;
|
||||||
|
arrow.y = 0;
|
||||||
|
} else if (direction === "left") {
|
||||||
|
arrow.width = 112;
|
||||||
|
arrow.height = 82;
|
||||||
|
arrow.anchor.set(1, 0.5);
|
||||||
|
arrow.x = -CIRCLE_D / 2 + 12;
|
||||||
|
arrow.y = 0;
|
||||||
|
} else {
|
||||||
|
// diagonal orientations
|
||||||
|
arrow.width = 93;
|
||||||
|
arrow.height = 97;
|
||||||
|
// bottom-right: top-left corner flush to marker
|
||||||
|
if (direction === "bottom-right") {
|
||||||
|
arrow.anchor.set(0, 0);
|
||||||
|
arrow.x = 0;
|
||||||
|
arrow.y = 0;
|
||||||
|
}
|
||||||
|
// top-right: bottom-left corner flush
|
||||||
|
else if (direction === "top-right") {
|
||||||
|
arrow.anchor.set(0, 1);
|
||||||
|
arrow.x = 0;
|
||||||
|
arrow.y = 0;
|
||||||
|
}
|
||||||
|
// bottom-left: top-right corner flush
|
||||||
|
else if (direction === "bottom-left") {
|
||||||
|
arrow.anchor.set(1, 0);
|
||||||
|
arrow.x = 0;
|
||||||
|
arrow.y = 0;
|
||||||
|
}
|
||||||
|
// top-left: bottom-right corner flush
|
||||||
|
else if (direction === "top-left") {
|
||||||
|
arrow.anchor.set(1, 1);
|
||||||
|
arrow.x = 0;
|
||||||
|
arrow.y = 0;
|
||||||
|
}
|
||||||
|
// Removed any diagonal rotation or scale flips
|
||||||
|
}
|
||||||
|
// keep arrow upright regardless of route rotation
|
||||||
|
arrow.rotation = -this.routeGroup.rotation;
|
||||||
|
this.tramContainer.addChild(arrow);
|
||||||
|
this.tramArrow = arrow;
|
||||||
|
this.tramDirection = direction;
|
||||||
|
this.routeGroup.addChild(this.tramContainer);
|
||||||
|
}
|
||||||
|
// update existing arrow sprite on direction change
|
||||||
|
if (this.tramArrow && direction !== this.tramDirection) {
|
||||||
|
const arrow = this.tramArrow;
|
||||||
|
const projSrc = svgMap[direction];
|
||||||
|
arrow.texture = PIXI.Texture.from(projSrc);
|
||||||
|
// size & anchor & position logic (same as creation)
|
||||||
|
const CIRCLE_D = 28;
|
||||||
|
if (direction === "right") {
|
||||||
|
arrow.width = 112;
|
||||||
|
arrow.height = 82;
|
||||||
|
arrow.anchor.set(0, 0.5);
|
||||||
|
arrow.x = CIRCLE_D / 2 - 12;
|
||||||
|
arrow.y = 0;
|
||||||
|
} else if (direction === "left") {
|
||||||
|
arrow.width = 112;
|
||||||
|
arrow.height = 82;
|
||||||
|
arrow.anchor.set(1, 0.5);
|
||||||
|
arrow.x = -CIRCLE_D / 2 + 12;
|
||||||
|
arrow.y = 0;
|
||||||
|
} else {
|
||||||
|
arrow.width = 93;
|
||||||
|
arrow.height = 97;
|
||||||
|
if (direction === "bottom-right") {
|
||||||
|
arrow.anchor.set(0, 0);
|
||||||
|
arrow.x = 0;
|
||||||
|
arrow.y = 0;
|
||||||
|
} else if (direction === "top-right") {
|
||||||
|
arrow.anchor.set(0, 1);
|
||||||
|
arrow.x = 0;
|
||||||
|
arrow.y = 0;
|
||||||
|
} else if (direction === "bottom-left") {
|
||||||
|
arrow.anchor.set(1, 0);
|
||||||
|
arrow.x = 0;
|
||||||
|
arrow.y = 0;
|
||||||
|
} else if (direction === "top-left") {
|
||||||
|
arrow.anchor.set(1, 1);
|
||||||
|
arrow.x = 0;
|
||||||
|
arrow.y = 0;
|
||||||
|
}
|
||||||
|
// Removed any diagonal rotation or scale flips
|
||||||
|
}
|
||||||
|
// keep arrow upright regardless of route rotation
|
||||||
|
arrow.rotation = -this.routeGroup.rotation;
|
||||||
|
this.tramDirection = direction;
|
||||||
|
}
|
||||||
|
|
||||||
|
// позиционируем контейнер по маркеру
|
||||||
|
this.tramContainer.x = x;
|
||||||
|
this.tramContainer.y = y;
|
||||||
|
|
||||||
// update station marker colors based on tram progress
|
// update station marker colors based on tram progress
|
||||||
this.stationContainer.children.forEach((node) => {
|
this.stationContainer.children.forEach((node) => {
|
||||||
@ -851,6 +1077,43 @@ export default {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
// reset the inactivity timeout
|
||||||
|
resetInactivityTimer() {
|
||||||
|
clearTimeout(this.inactivityTimer);
|
||||||
|
this.inactivityTimer = setTimeout(() => {
|
||||||
|
if (this.tramContainer) {
|
||||||
|
// compute tram position in viewport world coordinates
|
||||||
|
const localPoint = new PIXI.Point(
|
||||||
|
this.tramContainer.x,
|
||||||
|
this.tramContainer.y
|
||||||
|
);
|
||||||
|
const worldCenter = this.viewport.toLocal(
|
||||||
|
localPoint,
|
||||||
|
this.routeGroup
|
||||||
|
);
|
||||||
|
// smooth pan and zoom to tram
|
||||||
|
this.viewport.animate({
|
||||||
|
time: 600,
|
||||||
|
position: { x: worldCenter.x, y: worldCenter.y },
|
||||||
|
scale: 1,
|
||||||
|
easing: "easeInOutSine",
|
||||||
|
});
|
||||||
|
// schedule next recenter
|
||||||
|
this.resetInactivityTimer();
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// start watching for user interactions
|
||||||
|
startInactivityWatcher() {
|
||||||
|
if (!this.viewport) return;
|
||||||
|
// reset on any user drag, wheel, or pinch
|
||||||
|
this.viewport.on("drag-start", this.resetInactivityTimer);
|
||||||
|
this.viewport.on("pointerdown", this.resetInactivityTimer);
|
||||||
|
this.viewport.on("wheel", this.resetInactivityTimer);
|
||||||
|
// initialize the timer
|
||||||
|
this.resetInactivityTimer();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
this.stopRoutePolling();
|
this.stopRoutePolling();
|
||||||
@ -858,6 +1121,13 @@ export default {
|
|||||||
clearInterval(this.tramPollTimer);
|
clearInterval(this.tramPollTimer);
|
||||||
this.tramPollTimer = null;
|
this.tramPollTimer = null;
|
||||||
}
|
}
|
||||||
|
if (this.inactivityInterval) {
|
||||||
|
clearInterval(this.inactivityInterval);
|
||||||
|
this.inactivityInterval = null;
|
||||||
|
}
|
||||||
|
["mousemove", "mousedown", "keydown", "touchstart", "scroll"].forEach(
|
||||||
|
(evt) => window.removeEventListener(evt, this.resetInactivity)
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
30
src/icons/tram-icon.svg
Normal file
30
src/icons/tram-icon.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 62 KiB |
Reference in New Issue
Block a user