tram icon, video-previews, resizing when inactive
All checks were successful
release-tag / release-image (push) Successful in 53s

This commit is contained in:
2025-07-10 07:58:33 +03:00
parent 12c2a678a5
commit 17b95707e7
2 changed files with 398 additions and 98 deletions

View File

@ -28,6 +28,20 @@ import { Viewport } from "pixi-viewport";
import { Text, TextStyle, FillGradient, Assets } from "pixi.js";
import { API_URL, GEO_URL } from "../config";
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 {
name: "MainMap",
@ -38,6 +52,9 @@ export default {
viewport: null,
routeGraphics: null,
videoOverlayVisible: false,
lastActivityTime: Date.now(),
inactivityInterval: null,
lastVideoPreviewId: null,
stationContainer: null,
sightsContainer: null,
toCanvasPoint: null,
@ -45,15 +62,63 @@ export default {
attemptCount: 0,
routeGroup: null,
routeLatlngs: [],
sights: [],
tramPollTimer: null,
passedGraphics: null,
remainingGraphics: 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,
routeCentered: false,
viewportAdjusted: false, // авто‑фит сделан?
clampApplied: false, // границы перемещения выставлены?
stationZoomed: false, // приблизили ли к станции?
viewportAdjusted: false,
clampApplied: false,
stationZoomed: false,
centerLatitude: null,
centerLongitude: null,
};
@ -62,9 +127,93 @@ export default {
await this.createPixiApp();
await this.fetchContext();
this.startRoutePolling();
this.startInactivityWatcher();
this.setupActivityListeners();
this.startInactivityCheck();
},
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() {
const container = document.getElementById("map");
if (!container || this.pixiApp) return;
@ -80,6 +229,14 @@ export default {
// add canvas to DOM (missing after last refactor)
container.appendChild(this.pixiApp.canvas);
await Assets.load(sightIcon);
await Assets.load([
tramRight,
tramLeft,
tramTopRight,
tramBottomRight,
tramTopLeft,
tramBottomLeft,
]);
// гарантируем, что канвас всегда принимает pointer/touchсобытия
this.pixiApp.canvas.style.pointerEvents = "auto";
@ -381,28 +538,9 @@ export default {
// гарантируем, что подписи горизонтальны
this.updateLabelOrientation();
/* ─── Center the view on the first station (once, keep current zoom) ─── */
if (!this.stationZoomed && this.stationContainer.children.length) {
const firstNode = this.stationContainer.children[0];
if (firstNode) {
// мировые координаты станции (учитывают поворот/масштаб 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"
);
}
this.autoFitViewport();
this.stationZoomed = true;
}
},
@ -447,49 +585,29 @@ export default {
const sw = this.viewport.screenWidth;
const sh = this.viewport.screenHeight;
// Подбираем масштаб так, чтобы занять ~90% экрана
const fill = 0.9;
let scale = Math.min(sw / b.width, sh / b.height) * fill;
let scale = Math.min(sw / b.width, sh / b.height);
// Учитываем scale_min / scale_max, если есть
if (this.routeScaleMin !== null)
scale = Math.max(scale, this.routeScaleMin / 10);
if (this.routeScaleMax !== null)
scale = Math.min(scale, this.routeScaleMax / 10);
if (this.centerLatitude != null && this.centerLongitude != null) {
let target;
if (this.tramContainer) {
// глобальная позиция контейнера с трамваем
const worldPos = this.tramContainer.getGlobalPosition();
target = worldPos;
} else if (this.centerLatitude != null && this.centerLongitude != null) {
const local = this.toCanvasPoint([
this.centerLatitude,
this.centerLongitude,
]);
const global = this.routeGroup.toGlobal(
new PIXI.Point(local.x, local.y)
);
this.viewport.setZoom(scale, global.x, global.y);
target = this.routeGroup.toGlobal(new PIXI.Point(local.x, local.y));
} else {
const cx = b.x + b.width / 2;
const cy = b.y + b.height / 2;
this.viewport.setZoom(scale, cx, cy);
target = new PIXI.Point(b.x + b.width / 2, b.y + b.height / 2);
}
this.viewport.animate({
time: 600,
scale: scale,
position: { x: target.x, y: target.y },
easing: "easeInOutSine",
});
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() {
return;
@ -564,6 +682,7 @@ export default {
),
fetch(`${API_URL}/route/${this.routeId}/sight`).then((r) => r.json()),
]);
this.sights = sights;
console.log(
`%cFetchRoute attempt #${this.attemptCount}`,
@ -609,25 +728,10 @@ export default {
this.drawSights(sights);
// compensate rotation for sight icons
this.updateLabelOrientation();
// после линии, станций и (возможного) поворота автоматически подгоняем viewport
this.autoFitViewport();
// сначала обновляем позицию трамвая, чтобы был this.tramContainer
this.updateTramPosition();
// center view on route-provided center after route load
if (this.centerLatitude != null && this.centerLongitude != null) {
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",
});
}
// затем корректно центрируем и зумим вид
this.autoFitViewport();
gotPath = true;
} else {
console.warn(
@ -646,6 +750,10 @@ export default {
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 ──
async updateTramPosition() {
if (!this.routeLatlngs || !this.routeLatlngs.length) return;
@ -694,28 +802,146 @@ export default {
// draw or update tram marker
const current = this.routeLatlngs[passedCount];
const { x, y } = this.toCanvasPoint(current);
if (!this.tramGraphic) {
this.tramGraphic = new PIXI.Graphics();
// set fill style to yellow
this.tramGraphic.fill(0xfcd500);
// set stroke style for border
this.tramGraphic.setStrokeStyle({
width: 5,
color: 0x000000,
alignment: 0.5,
alpha: 1,
});
// 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);
let direction;
const now = Date.now();
if (this.manualTramDirection && this.manualDirectionUntil > now) {
direction = this.manualTramDirection;
} else {
direction = this.chooseTramDirection([x, y]);
// clear expired override
if (this.manualDirectionUntil <= now) {
this.manualTramDirection = null;
}
}
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
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() {
this.stopRoutePolling();
@ -858,6 +1121,13 @@ export default {
clearInterval(this.tramPollTimer);
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>

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