carrier info fix, right widget update, live tracking device upd, bug fixes
All checks were successful
release-tag / release-image (push) Successful in 55s

This commit is contained in:
2025-07-13 14:43:24 +03:00
parent 17b95707e7
commit 49576a0be7
5 changed files with 602 additions and 716 deletions

View File

@ -112,6 +112,24 @@ body {
margin-bottom: 15px; margin-bottom: 15px;
} }
.back-button {
border: none;
font-size: 22px;
font-weight: 600;
text-align: left;
padding: 15px;
color: #fff;
background: rgb(187, 179, 170);
background: linear-gradient(
180deg,
rgba(187, 179, 170, 1) 0%,
rgba(159, 148, 135, 1) 100%
);
display: flex;
align-items: center;
gap: 10px;
}
.stopdescription { .stopdescription {
font-size: 16px; font-size: 16px;
font-weight: 400; font-weight: 400;
@ -687,6 +705,7 @@ body {
} }
.dropdown-name { .dropdown-name {
position: relative;
font-size: 18px; font-size: 18px;
font-weight: 600; font-weight: 600;
color: #fff; color: #fff;
@ -695,7 +714,6 @@ body {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center;
width: 100%; width: 100%;
} }
@ -1231,3 +1249,25 @@ li.checked {
flex: 0 0 calc((100% - 24px) / 3); flex: 0 0 calc((100% - 24px) / 3);
box-sizing: border-box; box-sizing: border-box;
} }
.carrier-container {
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 15px;
}
.carrier-img {
width: 100%;
padding: 0;
margin: 0;
}
.carrier-slogan {
font-size: 18px;
color: #fff;
opacity: 70%;
text-align: center;
font-style: italic;
}

File diff suppressed because one or more lines are too long

View File

@ -74,6 +74,8 @@ export default {
manualTramDirection: null, manualTramDirection: null,
manualDirectionUntil: 0, manualDirectionUntil: 0,
inactivityTimer: null, inactivityTimer: null,
isFollowingTram: false,
followTramTimer: null,
chooseTramDirection(canvasPt) { chooseTramDirection(canvasPt) {
// canvasPt: [x, y] in PIXI coords // canvasPt: [x, y] in PIXI coords
let bestDir = "right"; let bestDir = "right";
@ -141,6 +143,12 @@ export default {
resetInactivity() { resetInactivity() {
this.lastActivityTime = Date.now(); this.lastActivityTime = Date.now();
if (this.videoOverlayVisible) this.hideVideoOverlay(); if (this.videoOverlayVisible) this.hideVideoOverlay();
clearTimeout(this.inactivityTimer);
// stop following on any user activity
this.isFollowingTram = false;
this.stopFollowTram();
// restart inactivity timer on any user activity
this.resetInactivityTimer();
}, },
startInactivityCheck() { startInactivityCheck() {
if (this.inactivityInterval) return; if (this.inactivityInterval) return;
@ -325,20 +333,17 @@ export default {
const minLat = Math.min(...lats); const minLat = Math.min(...lats);
const maxLat = Math.max(...lats); const maxLat = Math.max(...lats);
const minLng = Math.min(...lngs); const minLng = Math.min(...lngs);
const maxLng = Math.max(...lngs);
const canvas = this.pixiApp.canvas;
const width = canvas.width;
const height = canvas.height;
const padding = 40; const padding = 40;
const scaleX = (width - padding * 2) / (maxLng - minLng || 1); const scale = 35000;
const scaleY = (height - padding * 2) / (maxLat - minLat || 1); const midLat = (minLat + maxLat) / 2;
let scale = 30000; const latFactor = Math.cos((midLat * Math.PI) / 180);
console.log(scaleX, scaleY, scale); console.log(
`Using fixed scale ${scale} and latFactor ${latFactor.toFixed(4)}`
);
const toPoint = ([lat, lng]) => { const toPoint = ([lat, lng]) => {
const x = (lng - minLng) * scale + padding; const x = (lng - minLng) * scale * latFactor + padding;
const y = (maxLat - lat) * scale + padding; const y = (maxLat - lat) * scale + padding;
return { x, y }; return { x, y };
}; };
@ -1079,29 +1084,39 @@ export default {
}, },
// reset the inactivity timeout // reset the inactivity timeout
resetInactivityTimer() { resetInactivityTimer() {
console.log("[inactivity] scheduling follow in 15s");
clearTimeout(this.inactivityTimer); clearTimeout(this.inactivityTimer);
this.inactivityTimer = setTimeout(() => { this.inactivityTimer = setTimeout(() => {
this.isFollowingTram = true;
this.startFollowTram();
}, 15000);
},
startFollowTram() {
console.log("[follow] startFollowTram() triggered");
if (this.followTramTimer) return;
this.followTramTimer = setInterval(() => {
if (this.tramContainer) { if (this.tramContainer) {
// compute tram position in viewport world coordinates const local = new PIXI.Point(
const localPoint = new PIXI.Point(
this.tramContainer.x, this.tramContainer.x,
this.tramContainer.y this.tramContainer.y
); );
const worldCenter = this.viewport.toLocal( const worldCenter = this.viewport.toLocal(local, this.routeGroup);
localPoint,
this.routeGroup
);
// smooth pan and zoom to tram
this.viewport.animate({ this.viewport.animate({
time: 600, time: 600,
position: { x: worldCenter.x, y: worldCenter.y }, position: { x: worldCenter.x, y: worldCenter.y },
scale: 1, scale: 1,
easing: "easeInOutSine", easing: "easeInOutSine",
}); });
// schedule next recenter
this.resetInactivityTimer();
} }
}, 15000); }, 500);
},
stopFollowTram() {
console.log("[follow] stopFollowTram() triggered");
if (this.followTramTimer) {
clearInterval(this.followTramTimer);
this.followTramTimer = null;
}
}, },
// start watching for user interactions // start watching for user interactions
@ -1125,6 +1140,10 @@ export default {
clearInterval(this.inactivityInterval); clearInterval(this.inactivityInterval);
this.inactivityInterval = null; this.inactivityInterval = null;
} }
if (this.followTramTimer) {
clearInterval(this.followTramTimer);
this.followTramTimer = null;
}
["mousemove", "mousedown", "keydown", "touchstart", "scroll"].forEach( ["mousemove", "mousedown", "keydown", "touchstart", "scroll"].forEach(
(evt) => window.removeEventListener(evt, this.resetInactivity) (evt) => window.removeEventListener(evt, this.resetInactivity)
); );

View File

@ -43,6 +43,26 @@
class="watermark watermark-rd" class="watermark watermark-rd"
/> />
</div> </div>
<button
v-if="showBackButton"
class="back-button"
@click="returnToNearestSight"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="21"
fill="none"
viewBox="0 0 12 21"
>
<path
fill="#fff"
d="M3.53 10.574c.188.135.316.203.414.3 2.486 2.495 4.966 4.992 7.451 7.486.445.447.623.96.44 1.574a1.485 1.485 0 0 1-2.383.716c-.09-.076-.171-.16-.254-.244L.658 11.82c-.878-.885-.877-1.673.003-2.56C3.539 6.364 6.414 3.463 9.307.58c.252-.25.606-.46.948-.542.646-.154 1.26.184 1.563.75.307.566.22 1.274-.238 1.764-.352.378-.725.736-1.09 1.101-2.195 2.204-4.389 4.407-6.585 6.609-.082.083-.179.15-.374.312Z"
/>
</svg>
{{ t("back") }}
</button>
<span class="stopname">{{ stopName }}</span> <span class="stopname">{{ stopName }}</span>
<span class="stopdescription">{{ selectedArticleBody }}</span> <span class="stopdescription">{{ selectedArticleBody }}</span>
<div class="stoparticles"> <div class="stoparticles">
@ -261,6 +281,11 @@ export default {
defaultImageUrl: defaultImageUrl:
"https://lh3.googleusercontent.com/gps-cs-s/AB5caB8lUwofb2NIg6n0-cEl8nIWsySAUc52KNj4XezuOdo-aeqTgQlD1kTVa5MaynL2Yg4ByoTYTKNTR7K59f7kjzU9yzpudstjRiT2F6M_ilxFYFpcvMZz6OwlRFF2MrsCPSwUa7vqew=s680-w680-h510", "https://lh3.googleusercontent.com/gps-cs-s/AB5caB8lUwofb2NIg6n0-cEl8nIWsySAUc52KNj4XezuOdo-aeqTgQlD1kTVa5MaynL2Yg4ByoTYTKNTR7K59f7kjzU9yzpudstjRiT2F6M_ilxFYFpcvMZz6OwlRFF2MrsCPSwUa7vqew=s680-w680-h510",
sightId: 17, sightId: 17,
manualSightId: null,
nearestSightId: null,
returnTimer: null,
lastUserActivity: Date.now(),
firstLoad: true,
stopName: "", stopName: "",
watermarkLU: "", watermarkLU: "",
watermarkRD: "", watermarkRD: "",
@ -291,10 +316,10 @@ export default {
routeProgress: null, routeProgress: null,
routeId: null, routeId: null,
selectedLang: localStorage.getItem("selectedLangRight") || "ru", selectedLang: localStorage.getItem("selectedLangRight") || "ru",
showLangToggle: true, // видна «глобус»-кнопка showLangToggle: true,
showLanguageOptions: false, // виден блок из трёх иконок showLanguageOptions: false,
languageOptionsTimer: null, // таймер на 10 с languageOptionsTimer: null,
langRevertTimer: null, // таймер на 30 с langRevertTimer: null,
icons: { icons: {
ru: `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="none" viewBox="0 0 28 28"> ru: `<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="none" viewBox="0 0 28 28">
<path fill="#fff" d="M14 0C6.27 0 0 6.27 0 14s6.27 14 14 14 14-6.27 14-14S21.73 0 14 0Zm.117 19.57H11.62l-2.117-4.135H7.646v4.136H5.32V8.27h4.2c1.336 0 2.363.298 3.092.893.729.595 1.085 1.435 1.085 2.52 0 .77-.17 1.412-.502 1.931-.332.513-.84.928-1.517 1.23l2.444 4.62v.112l-.005-.006Zm9.391-3.855c0 1.237-.385 2.217-1.16 2.934-.776.718-1.832 1.08-3.174 1.08-1.341 0-2.368-.35-3.144-1.05-.776-.7-1.173-1.657-1.19-2.882V8.272h2.328v7.46c0 .741.175 1.278.53 1.616.356.339.846.508 1.47.508 1.307 0 1.972-.689 1.995-2.065V8.27h2.334v7.444h.011Z"/> <path fill="#fff" d="M14 0C6.27 0 0 6.27 0 14s6.27 14 14 14 14-6.27 14-14S21.73 0 14 0Zm.117 19.57H11.62l-2.117-4.135H7.646v4.136H5.32V8.27h4.2c1.336 0 2.363.298 3.092.893.729.595 1.085 1.435 1.085 2.52 0 .77-.17 1.412-.502 1.931-.332.513-.84.928-1.517 1.23l2.444 4.62v.112l-.005-.006Zm9.391-3.855c0 1.237-.385 2.217-1.16 2.934-.776.718-1.832 1.08-3.174 1.08-1.341 0-2.368-.35-3.144-1.05-.776-.7-1.173-1.657-1.19-2.882V8.272h2.328v7.46c0 .741.175 1.278.53 1.616.356.339.846.508 1.47.508 1.307 0 1.972-.689 1.995-2.065V8.27h2.334v7.444h.011Z"/>
@ -310,7 +335,7 @@ export default {
}, },
translations: { translations: {
sights: { ru: "Достопримечательности", en: "Landmarks", zh: "景点" }, sights: { ru: "Достопримечательности", en: "Landmarks", zh: "景点" },
// при необходимости — другие подписи back: { ru: "Вернуться назад", en: "Back", zh: "返回" },
}, },
}; };
}, },
@ -352,6 +377,9 @@ export default {
Object.entries(this.nextStopTransfers).filter(([, value]) => value) Object.entries(this.nextStopTransfers).filter(([, value]) => value)
); );
}, },
showBackButton() {
return this.sightId !== this.nearestSightId;
},
}, },
methods: { methods: {
t(key) { t(key) {
@ -513,86 +541,14 @@ export default {
} }
}, },
async openSightCardDetails(id) { async openSightCardDetails(id) {
// закрыть, если нажали повторно this.manualSightId = id;
if (this.selectedSightCardId === id) { this.sightId = id;
this.showSightsList = false;
this.selectedSightCardId = null; this.selectedSightCardId = null;
this.cardDetail = null; this.cardDetail = null;
return; this.resetReturnTimer();
} await this.fetchSightInfo();
this.selectedSightCardId = id; await this.fetchArticles();
this.cardDetail = null;
this.resetSightsInactivityTimer();
try {
const [detailRes, articlesRes] = await Promise.all([
axios.get(this.addLangParam(`${API_URL}/sight/${id}`)),
axios.get(this.addLangParam(`${API_URL}/sight/${id}/article`)),
]);
// Fetch media for every article
const articlesWithMedia = await Promise.all(
articlesRes.data.map(async (article) => {
try {
const mediaRes = await axios.get(
this.addLangParam(`${API_URL}/article/${article.id}/media`)
);
return { ...article, media: mediaRes.data };
} catch (mediaErr) {
console.error(
`Failed to fetch media for article ${article.id}:`,
mediaErr
);
return { ...article, media: [] };
}
})
);
// Console output: sight name, articles, and their media
console.log("Sight clicked:", {
name: detailRes.data.name,
articles: articlesWithMedia,
});
// Choose preview image: prefer the first article's first media, otherwise fall back to sight thumbnail
let imageUrl = "";
if (
articlesWithMedia.length > 0 &&
articlesWithMedia[0].media &&
articlesWithMedia[0].media.length > 0
) {
const firstMediaId = articlesWithMedia[0].media[0].id;
try {
imageUrl = await this.getMediaBlobUrl(firstMediaId);
} catch {
imageUrl = this.addLangParam(
`${API_URL}/media/${firstMediaId}/download`
);
}
} else if (detailRes.data.thumbnail) {
try {
imageUrl = await this.getMediaBlobUrl(detailRes.data.thumbnail);
} catch {
imageUrl = this.addLangParam(
`${API_URL}/media/${detailRes.data.thumbnail}/download`
);
}
}
this.cardDetail = {
name: detailRes.data.name,
imageUrl,
articles: articlesWithMedia,
};
if (articlesWithMedia.length > 0) {
this.selectedSightArticleId = articlesWithMedia[0].id;
this.selectedSightArticleBody = articlesWithMedia[0].body;
} else {
this.selectedSightArticleId = null;
this.selectedSightArticleBody = "";
}
} catch (err) {
console.error("Failed to load sight card details:", err);
}
}, },
async selectSightArticle(id) { async selectSightArticle(id) {
this.selectedSightArticleId = id; this.selectedSightArticleId = id;
@ -657,10 +613,12 @@ export default {
}, 300_000); // 5 m }, 300_000); // 5 m
}, },
handleUserActivity() { handleUserActivity() {
this.lastUserActivity = Date.now();
if (this.showSightsList || this.cardDetail) if (this.showSightsList || this.cardDetail)
this.resetSightsInactivityTimer(); this.resetSightsInactivityTimer();
this.resetArticleInactivityTimer(); this.resetArticleInactivityTimer();
this.resetLangRevertTimer(); this.resetLangRevertTimer();
if (this.returnTimer) this.resetReturnTimer();
}, },
selectArticle(id) { selectArticle(id) {
this.resetArticleInactivityTimer(); this.resetArticleInactivityTimer();
@ -738,23 +696,41 @@ export default {
newSightId = this.sights[1].id; newSightId = this.sights[1].id;
} }
} }
if (newSightId && newSightId !== this.sightId) { if (newSightId) {
if (this.firstLoad) {
this.nearestSightId = newSightId;
this.sightId = newSightId; this.sightId = newSightId;
await this.fetchSightInfo(); await this.fetchSightInfo();
await this.fetchArticles(); await this.fetchArticles();
this.firstLoad = false;
this.clearReturnTimer();
return;
}
if (this.nearestSightId !== newSightId) {
this.nearestSightId = newSightId;
}
if (!this.manualSightId) {
if (this.sightId !== this.nearestSightId) {
const userActive = Date.now() - this.lastUserActivity < 15_000; // 15-сек. окно
if (userActive) {
this.resetReturnTimer();
} else {
this.clearReturnTimer();
this.sightId = this.nearestSightId;
await this.fetchSightInfo();
await this.fetchArticles();
}
} else {
this.clearReturnTimer();
}
}
} }
const nextStopId = response.data.routeProgress?.endStopId; const nextStopId = response.data.routeProgress?.endStopId;
// console.log("Fetched next stop ID:", nextStopId);
// console.log("Stops:", this.stops);
if (nextStopId && this.stops) { if (nextStopId && this.stops) {
const nextStop = this.stops.find((stop) => stop.id == nextStopId); const nextStop = this.stops.find((stop) => stop.id == nextStopId);
// console.log("Fetched next stop ID:", nextStopId);
// console.log("Matching stop:", nextStop);
if (nextStop && nextStop.transfers) { if (nextStop && nextStop.transfers) {
// console.log("Transfers at next stop:", nextStop.transfers);
this.nextStopTransfers = nextStop.transfers; this.nextStopTransfers = nextStop.transfers;
} else { } else {
// console.log("No transfers found at next stop");
this.nextStopTransfers = null; this.nextStopTransfers = null;
} }
} }
@ -762,6 +738,29 @@ export default {
console.error("Error fetching geolocation context:", error); console.error("Error fetching geolocation context:", error);
} }
}, },
resetReturnTimer() {
this.clearReturnTimer();
this.returnTimer = setTimeout(() => {
this.returnToNearestSight();
}, 90_000);
},
clearReturnTimer() {
if (this.returnTimer) {
clearTimeout(this.returnTimer);
this.returnTimer = null;
}
},
returnToNearestSight() {
if (!this.nearestSightId) return;
this.manualSightId = null;
this.sightId = this.nearestSightId;
this.clearReturnTimer();
this.showSightsList = false;
this.selectedSightCardId = null;
this.cardDetail = null;
this.fetchSightInfo();
this.fetchArticles();
},
selectLetter(letter) { selectLetter(letter) {
const anchorArr = this.$refs[`letter-${letter}`]; const anchorArr = this.$refs[`letter-${letter}`];
const anchor = anchorArr ? anchorArr[0] : null; const anchor = anchorArr ? anchorArr[0] : null;
@ -818,6 +817,7 @@ export default {
async mounted() { async mounted() {
await this.fetchSights(); await this.fetchSights();
await this.fetchGeolocationContext(); await this.fetchGeolocationContext();
this.nearestSightId = this.sightId;
this.geolocationInterval = setInterval(() => { this.geolocationInterval = setInterval(() => {
this.fetchGeolocationContext(); this.fetchGeolocationContext();
}, 1000); }, 1000);

373
src/icons/spb-gerb.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 176 KiB