From 6885bba1e8a444dbef88f2b518ddf682764b664d Mon Sep 17 00:00:00 2001 From: lol Date: Sat, 13 Jun 2026 23:32:19 +0200 Subject: [PATCH] Ajout des velov et des derniers passages --- lol/components/marquee.js | 205 +++++++++++++++++++ lol/components/next-bus.js | 127 +++++++++++- lol/components/velov-station.js | 62 ++++++ lol/index.html | 348 +++++++++++++++++++------------- lol/tcl.js | 49 ++++- 5 files changed, 644 insertions(+), 147 deletions(-) create mode 100644 lol/components/marquee.js create mode 100644 lol/components/velov-station.js diff --git a/lol/components/marquee.js b/lol/components/marquee.js new file mode 100644 index 0000000..a0d1eb6 --- /dev/null +++ b/lol/components/marquee.js @@ -0,0 +1,205 @@ +const TEMPLATE = document.createElement("template"); +TEMPLATE.innerHTML = ``; + +const CHECK_OPTIONS_INTERVAL = 100; + +class MarkeeComponent extends HTMLElement { + #options = { + speed: 0, + scrollPauseDelay: 1000, + playing: true, + scrollPlaying: true, + }; + + #lastFrame = 0; + + #animationFrameRequest = -1; + + #checkOptionInterval = -1; + + #scrollPlayingTimeout = -1; + + /** @type {{element: Element, rect: DOMRect, margin: number}?} */ + #nextCollectedElement = null; + + constructor() { + super(); + + this.requestTick = this.requestTick.bind(this); + this.updateOptions = this.updateOptions.bind(this); + + this.attachShadow({ mode: "open" }); + this.shadowRoot.appendChild(TEMPLATE.content.cloneNode(true)); + } + + tick() { + let now = Date.now(); + let delta = (now - this.#lastFrame) / 1000; + this.#lastFrame = now; + + if (!this.#options.playing || !this.#options.scrollPlaying || this.scrollWidth - 800 <= this.clientWidth) return; + + this.scrollLeft += Math.max(this.#options.speed * delta, 1); + + this.checkElementRecycling(); + } + + requestTick() { + this.tick(); + if (this.#options.playing) { + this.#animationFrameRequest = requestAnimationFrame(this.requestTick, 30); + } + } + + updateOptions() { + let style = window.getComputedStyle(this); + + let speed = parseFloat(style.getPropertyValue("--markee-speed")); + if (!Number.isNaN(speed)) { + this.#options.speed = speed; + } + + let wasPlaying = this.#options.playing; + + let playState = style.getPropertyValue("--markee-play-state"); + if (playState) { + this.#options.playing = playState != "paused"; + } else { + this.#options.playing = true; + } + + let scrollPauseDelay = style.getPropertyValue( + "--markee-interaction-pause-delay" + ); + if (scrollPauseDelay) { + let value = parseFloat(scrollPauseDelay); + if (scrollPauseDelay.endsWith("ms")) { + this.#options.scrollPauseDelay = value; + } else if (scrollPauseDelay.endsWith("s")) { + this.#options.scrollPauseDelay = value * 1000; + } else { + this.#options.scrollPauseDelay = 1000; + } + } + + if (wasPlaying != this.#options.playing) { + this.#lastFrame = Date.now(); + cancelAnimationFrame(this.#animationFrameRequest); + this.requestTick(); + } + } + + checkElementRecycling() { + if ( + this.children.length > 0 && + (!this.#nextCollectedElement || + this.#nextCollectedElement.element != this.children[0]) + ) { + let element = this.children[0]; + { + let i = 0; + while(element.style.display == "none" && i < this.children.length){ + i += 1; + element = this.children[i] + } + } + + let style = window.getComputedStyle(element); + + this.#nextCollectedElement = { + element, + rect: element.getBoundingClientRect(), + margin: parseFloat(style.marginLeft) + parseFloat(style.marginRight), + }; + } + + if (!this.#nextCollectedElement) return; + + let lastElement = this.children[this.children.length-1]; + { + let i = lastElement.children.length-1; + while(lastElement && lastElement.style.display == "none" && i >= 0){ + i -= 1; + lastElement = this.children[i] + } + } + + + if (lastElement) { + let rect = lastElement.getBoundingClientRect(); + let thisRect = this.getBoundingClientRect(); + let style = window.getComputedStyle(lastElement); + let margin = parseFloat(style.marginLeft) + parseFloat(style.marginRight); + + if (thisRect.width == 0) { + return; + } + + if (rect.right + margin < thisRect.left + this.clientWidth) { + return; + } + } + + if ( + this.scrollLeft >= + this.scrollWidth - + this.clientWidth - + this.#nextCollectedElement.rect.width - + this.#nextCollectedElement.margin + ) { + this.scrollLeft -= + this.#nextCollectedElement.rect.width + + this.#nextCollectedElement.margin; + this.appendChild(this.#nextCollectedElement.element); + this.#nextCollectedElement = null; + } + } + + handlePointer(e) { + this.#options.scrollPlaying = false; + clearTimeout(this.#scrollPlayingTimeout); + this.#scrollPlayingTimeout = setTimeout(() => { + this.#options.scrollPlaying = true; + }, this.#options.scrollPauseDelay); + } + + connectedCallback() { + this.updateOptions(); + this.#lastFrame = Date.now(); + this.requestTick(); + this.#checkOptionInterval = setInterval( + this.updateOptions, + CHECK_OPTIONS_INTERVAL + ); + } + + disconnectedCallback() { + clearInterval(this.#checkOptionInterval); + } +} + +customElements.define("gavle-marquee", MarkeeComponent); diff --git a/lol/components/next-bus.js b/lol/components/next-bus.js index d7353af..4264a22 100644 --- a/lol/components/next-bus.js +++ b/lol/components/next-bus.js @@ -1,7 +1,27 @@ +import { getNextPassage } from "../tcl.js"; + +const LAST_PASSAGE_WARNING_THREASHOLD_MS = 30*60*1000; + const TEMPLATE = document.createElement("template"); +TEMPLATE.innerHTML = ` + +

+ vers + +

+

+ Prochain passage + () +

+

+ Prochain et Dernier passage + () +

+` class NextBusElement extends HTMLElement { #stop + #autoupdateTimeout get stopId(){ return this.#stop @@ -9,15 +29,118 @@ class NextBusElement extends HTMLElement { set stopId(value){ this.#stop = value + } + + handleAutoUpdate(){ + clearTimeout(this.#autoupdateTimeout) this.updateContent() + this.#autoupdateTimeout = setTimeout(this.handleAutoUpdate.bind(this), 60000 + ((Math.random() * 10000) - 5000)) } connectedCallback(){ this.replaceChildren(TEMPLATE.content.cloneNode(true)) + this.handleAutoUpdate() } - updateContent(){ + async updateContent(){ + try { + let nextPassage = await getNextPassage(this.stopId) + let time = nextPassage.time + + if(!time){ + time = nextPassage.line.timetable.find(it => it.time.getTime() > Date.now())?.time + } + + if(!time){ + return + } + + this.querySelector(".line-picto").src = nextPassage.line.picto + this.querySelector(".line-name").textContent = nextPassage.line.displayName + this.querySelector(".stop-name").textContent = nextPassage.displayName + + let timeFormatter = new Intl.DateTimeFormat("fr-FR", { + timeStyle: "short" + }); + let timeEl = this.querySelector(".stop-passage-time") + timeEl.textContent = timeFormatter.format(time) + + let relativeTimeFOrmatter = new Intl.RelativeTimeFormat("fr-FR", { + style: "short", + numeric: "auto" + }); + + let time_minutes = Math.floor((time.getTime() - Date.now()) / 60000) + + let relative_time_str; + if(time_minutes > 60) { + let time_hours = Math.floor(time_minutes / 60) + time_minutes -= time_hours * 60 + + relative_time_str = relativeTimeFOrmatter.format( + time_hours, + "hours" + ) + if(time_minutes > 0){ + relative_time_str += ` et ${Math.abs(time_minutes)} min` + } + } else { + relative_time_str = relativeTimeFOrmatter.format( + time_minutes, + "minutes" + ) + } + this.querySelector(".stop-passage-time-relative").textContent = relative_time_str + + { + let last_passage = nextPassage.getLastPassageTime() + if(last_passage){ + let time = last_passage.time + let timeEl = this.querySelector(".last-passage-time") + timeEl.textContent = timeFormatter.format(time) + + let time_minutes = Math.floor((time.getTime() - Date.now()) / 60000) + + let relative_time_str; + if(time_minutes > 60) { + let time_hours = Math.floor(time_minutes / 60) + time_minutes -= time_hours * 60 + + relative_time_str = relativeTimeFOrmatter.format( + time_hours, + "hours" + ) + if(time_minutes > 0){ + relative_time_str += ` et ${Math.abs(time_minutes)} min` + } + } else { + relative_time_str = relativeTimeFOrmatter.format( + time_minutes, + "minutes" + ) + } + this.querySelector(".last-passage-time-relative").textContent = relative_time_str + this.querySelector(".last-passage").style.display = "" + this.querySelector(".last-passage").classList.toggle("warning", (time.getTime() - Date.now()) <= LAST_PASSAGE_WARNING_THREASHOLD_MS) + } else { + this.querySelector(".last-passage").style.display = "none" + } + + if(last_passage && last_passage.time.getTime() == time.getTime()){ + this.querySelector(".next-and-last").style.display = "" + this.querySelector(".next-passage").style.display = "none" + } else { + this.querySelector(".next-and-last").style.display = "none" + this.querySelector(".next-passage").style.display = "" + } + } + + this.style.display = "" + } catch(e){ + this.style.display = "none" + throw e + } } static observedAttributes = ["stop-id"] @@ -31,4 +154,4 @@ class NextBusElement extends HTMLElement { } } -customElements.define("camp-next-bus", NextBusElement); \ No newline at end of file +customElements.define("gavle-next-bus", NextBusElement); \ No newline at end of file diff --git a/lol/components/velov-station.js b/lol/components/velov-station.js new file mode 100644 index 0000000..62b79ef --- /dev/null +++ b/lol/components/velov-station.js @@ -0,0 +1,62 @@ +import { getNextPassage, getVelovBikeState } from "../tcl.js"; + +const TEMPLATE = document.createElement("template"); +TEMPLATE.innerHTML = ` + +

+

vélos disponibles

+` + +class VelovStationElement extends HTMLElement { + #station + #autoupdateTimeout + + get stationId(){ + return this.#station + } + + set stationId(value){ + this.#station = value + } + + handleAutoUpdate(){ + clearTimeout(this.#autoupdateTimeout) + this.updateContent() + this.#autoupdateTimeout = setTimeout(this.handleAutoUpdate.bind(this), 60000 + ((Math.random() * 10000) - 5000)) + } + + connectedCallback(){ + this.replaceChildren(TEMPLATE.content.cloneNode(true)) + this.handleAutoUpdate() + } + + async updateContent(){ + try { + if(!this.stationId) + throw new Error("Missing station id") + + let station_data = await getVelovBikeState(this.stationId) + + this.querySelector(".station-name").textContent = station_data.name + this.querySelector(".bike-available").textContent = station_data.properties.available_bikes + this.querySelector(".bike-available").parentElement.classList.toggle("plurial-content", station_data.properties.available_bikes > 1); + + this.style.display = "" + } catch(e){ + this.style.display = "none" + throw e + } + } + + static observedAttributes = ["station-id"] + + attributeChangedCallback(name, oldVal, newVal){ + switch(name){ + case "station-id": + this.stationId = newVal + break; + } + } +} + +customElements.define("gavle-velov-station", VelovStationElement); \ No newline at end of file diff --git a/lol/index.html b/lol/index.html index 647209f..d5410ab 100644 --- a/lol/index.html +++ b/lol/index.html @@ -64,135 +64,165 @@ mix-blend-mode: darken; } - #next-line { - font-family: monospace; - color: white; - - box-sizing: border-box; - padding: 15px; - font-size: 20px; - - display: grid; - gap: 10px; - grid-template-columns: min-content 1fr; - grid-template-rows: min-content 1fr; + #next-bus-ribbon { position: absolute; - top: 0; + top: 50px; left: 0; width: 100%; - background: var(--tcl-red); - border: none; - z-index: 100; + } - transform: translateY(0); - transition: ease-out 0.8s transform; + gavle-marquee { + --markee-speed: 20; + --markee-interaction-pause-delay: 1s; + + width: 100vw; + + & > gavle-next-bus { + max-width: 75vw; + } + + & > * { + padding-left: 1.5em; + } + } + + gavle-next-bus { + + display: grid; + color: white; + font-family: sans-serif; + font-size: 1.5em; + + grid-template-columns: min-content 1fr; + grid-template-rows: 1fr min-content; + column-gap: 0.75em; + row-gap: 5px; + + & > :not(img) { + mix-blend-mode: exclusion; + } + + &[hidden] { + display: none; + } & > * { grid-column: 2; margin: 0; } - & > h1 { - font-size: 1.3em; - } - - & > img:first-child { + & > img { grid-column: 1; - grid-row: 1; - height: 1.3em; - align-self: center; + grid-row: 1 / 3; + align-self: flex-start; + } + + & h1 { + font-size: inherit; + } + + & h1 small { + font-size: inherit; + font-weight: normal; + display: block; + } + + & img { + height: 2em; + max-width: 3em; + margin-top: -5px; + + --outline-size: 1px; + --outline-color: white; + filter: + drop-shadow(0px var(--outline-size) 0px var(--outline-color)) + drop-shadow(var(--outline-size) 0px 0px var(--outline-color)) + drop-shadow(var(--outline-size) var(--outline-size) 0px var(--outline-color)) + drop-shadow(0px calc(var(--outline-size) * -1) 0px var(--outline-color)) + drop-shadow(calc(var(--outline-size) * -1) 0px 0px var(--outline-color)) + drop-shadow(calc(var(--outline-size) * -1) calc(var(--outline-size) * -1) 0px var(--outline-color)) + drop-shadow(calc(var(--outline-size) * -1) var(--outline-size) 0px var(--outline-color)) + drop-shadow(var(--outline-size) calc(var(--outline-size) * -1) 0px var(--outline-color)) + ; + } + + & p { + font-size: 0.8em; + } + + .last-passage.warning { + color: white; + + --outline-size: 1px; + --outline-color:#e2001a; + filter: + drop-shadow(0px var(--outline-size) 0px var(--outline-color)) + drop-shadow(var(--outline-size) 0px 0px var(--outline-color)) + drop-shadow(var(--outline-size) var(--outline-size) 0px var(--outline-color)) + drop-shadow(0px calc(var(--outline-size) * -1) 0px var(--outline-color)) + drop-shadow(calc(var(--outline-size) * -1) 0px 0px var(--outline-color)) + drop-shadow(calc(var(--outline-size) * -1) calc(var(--outline-size) * -1) 0px var(--outline-color)) + drop-shadow(calc(var(--outline-size) * -1) var(--outline-size) 0px var(--outline-color)) + drop-shadow(var(--outline-size) calc(var(--outline-size) * -1) 0px var(--outline-color)) + ; + + text-transform: uppercase; + mix-blend-mode: normal; + font-weight: normal; } } - #next-line[hidden] { - transform: translateY(-100%); - transition-timing-function: ease-in; - } - - #last-passage { - position: absolute; - bottom: 15px; - right: 15px; - max-width: calc(100% - 30px); - max-height: calc(100% - 30px); - - font-size: 20px; - font-family: monospace; + gavle-velov-station { + display: grid; color: white; + font-family: sans-serif; + font-size: 1.5em; - & > * { - background: var(--tcl-red); - padding: 15px; + grid-template-columns: min-content 1fr; + grid-template-rows: 1fr min-content; + column-gap: 0.75em; + row-gap: 5px; + + & > img { + grid-column: 1; + grid-row: 1 / 3; + align-self: flex-start; + } + + & h1 { + font-size: inherit; margin: 0; - margin-left: auto; + padding-right: 3em; + width: fit-content; } - & .last-passage { - - display: grid; - - &[hidden] { - display: none; - } - - grid-template-columns: min-content 1fr; - grid-template-rows: 1fr min-content; - column-gap: 10px; - row-gap: 5px; - - & > * { - grid-column: 2; - margin: 0; - } - - & > img { - grid-column: 1; - grid-row: 1 / 3; - align-self: flex-start; - } - - & h1 { - font-size: inherit; - } - - & h1 small { - font-size: inherit; - font-weight: normal; - display: inline-block; - } - - & img { - height: 2em; - max-width: 3em; - } - } - - - ul, ol { - padding-left: calc(15px + 2em); - margin: 0.75em 0; - padding-left: 1em; - - & > li:not(:last-child) { - margin-bottom: 0.75em; - } - } - } - - #next-line:not([hidden]) ~ #last-passage { - color: rgba(255, 255, 255, 0.5); - & img { - opacity: 0.5 + height: 1.3em; + max-width: 3em; + margin-top: 5px; + + --outline-size: 1px; + --outline-color: white; + filter: + drop-shadow(0px var(--outline-size) 0px var(--outline-color)) + drop-shadow(var(--outline-size) 0px 0px var(--outline-color)) + drop-shadow(var(--outline-size) var(--outline-size) 0px var(--outline-color)) + drop-shadow(0px calc(var(--outline-size) * -1) 0px var(--outline-color)) + drop-shadow(calc(var(--outline-size) * -1) 0px 0px var(--outline-color)) + drop-shadow(calc(var(--outline-size) * -1) calc(var(--outline-size) * -1) 0px var(--outline-color)) + drop-shadow(calc(var(--outline-size) * -1) var(--outline-size) 0px var(--outline-color)) + drop-shadow(var(--outline-size) calc(var(--outline-size) * -1) 0px var(--outline-color)) + ; } - } - #next-line ~ #last-passage { - transition: color 0.5s steps(5); + & p { + font-size: 0.8em; + margin: 0; + } - & img { - transition: opacity 0.5s steps(5); + & > :not(img) { + mix-blend-mode: exclusion; } } @@ -208,7 +238,7 @@ font-family: "bianzhidai nobg pearl"; position: fixed; bottom: 5px; - left: 15px; + right: 15px; font-size: 17vh; z-index: 1000; color: white; @@ -219,43 +249,83 @@ animation: deux-points steps(4) 1s infinite; } } + + .plurial { + display: none; + } + + .plurial-content .plurial { + display: inline; + } - - -
-
- Quelques petites choses a faire avant de partir : -
    -
  • Ranger les établis et les machines
  • -
  • Faire un petit coup de vaisselle
  • -
- Bonne nuit :) -
- -
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/lol/tcl.js b/lol/tcl.js index a1fcc01..4cab4e9 100644 --- a/lol/tcl.js +++ b/lol/tcl.js @@ -1,4 +1,5 @@ const DAY_IN_MS = 8.64e+7; +const MIN_TIME_BETWEEN_DAYS = 2 * 60 * 60000 // 2 hours class StopId { constructor(stopIdString){ @@ -138,27 +139,63 @@ export async function getNextPassage(stop_description, options = { } let [line, next_trip, line_stops, ...all_timetables] = await Promise.all(proms); - - console.log(stopId) - console.log("line", line) - console.log("next trip", next_trip) - console.log("stop", line_stops) - console.log("timetable", all_timetables) let line_timetables = [] for(let timetable of all_timetables){ line_timetables.push(...timetable) } line.timetable = line_timetables + line.getLastPassageTime = getLastPassageTime.bind(line) let stop = line_stops.find(it => it.id == stopId.stopId) + let time; + if(next_trip.schedules){ + let next_schedule = next_trip.schedules.find(it => (new Date(it.dateTime).getTime() - Date.now()) > 120000 ) + if(!next_schedule) { + next_schedule = next_trip.schedules[next_trip.schedules.length - 1] + } + if(next_schedule){ + time = new Date(next_schedule.dateTime) + } + } + let nextPassage = { line, displayName: stop?.name, displayTime: "", time: next_trip.schedules?.[0] ? new Date(next_trip.schedules?.[0].dateTime) : null } + nextPassage.getLastPassageTime = getLastPassageTime.bind(nextPassage) return nextPassage +} + +export async function getVelovBikeState(station_id){ + let res = await fetch(`/api/interface/tcl/realtime/navitia/${decodeURIComponent(station_id)}`) + if(!res.ok){ + throw new Error(`Server responded with ${res.status} ${res.statusText}`) + } + return await res.json() +} + +function getLastPassageTime(){ + let timetable = this.timetable || this.line.timetable + + if(!timetable){ + return + } + + let now = new Date() + + let last_passage = timetable + .find((it, i) => { + if((timetable[i+1]) && it.time.getTime() > now && (it.time.getTime() + MIN_TIME_BETWEEN_DAYS) < timetable[i+1].time.getTime()){ + return true + } else { + return false + } + }) + + return last_passage } \ No newline at end of file