diff --git a/src/components/main.vue b/src/components/main.vue index 9e24018..db57aae 100644 --- a/src/components/main.vue +++ b/src/components/main.vue @@ -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) + ); }, }; diff --git a/src/icons/tram-icon.svg b/src/icons/tram-icon.svg new file mode 100644 index 0000000..56a6f68 --- /dev/null +++ b/src/icons/tram-icon.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +