This commit is contained in:
lol
2026-06-13 20:42:03 +02:00
commit 58720611f7
12 changed files with 911 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "lol/liquid-shape-distortions"]
path = lol/liquid-shape-distortions
url = https://github.com/collidingScopes/liquid-shape-distortions
Binary file not shown.
+26
View File
@@ -0,0 +1,26 @@
const TEMPLATE = document.createElement("template");
TEMPLATE.innerHTML = `<span class="hours">--</span><span class="deux-points">:</span><span class="minutes">--</span>`
class ClockElement extends HTMLElement {
constructor(){
super()
setInterval(this.updateContent.bind(this), 1000);
}
connectedCallback(){
this.replaceChildren(TEMPLATE.content.cloneNode(true))
this.updateContent()
}
updateContent(){
if(!this.querySelector(".hours"))
return
let now = new Date();
this.querySelector(".hours").textContent = now.getHours().toString().padStart(2, "0")
this.querySelector(".minutes").textContent = now.getMinutes().toString().padStart(2, "0")
this.setAttribute("datetime", now.toISOString())
}
}
customElements.define("gavle-clock", ClockElement)
+34
View File
@@ -0,0 +1,34 @@
const TEMPLATE = document.createElement("template");
class NextBusElement extends HTMLElement {
#stop
get stopId(){
return this.#stop
}
set stopId(value){
this.#stop = value
this.updateContent()
}
connectedCallback(){
this.replaceChildren(TEMPLATE.content.cloneNode(true))
}
updateContent(){
}
static observedAttributes = ["stop-id"]
attributeChangedCallback(name, oldVal, newVal){
switch(name){
case "stop-id":
this.stopId = newVal
break;
}
}
}
customElements.define("camp-next-bus", NextBusElement);
+263
View File
@@ -0,0 +1,263 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GavleTv × Dernier métro</title>
<style>
@font-face {
font-family: "bianzhidai nobg pearl";
src: url("./bianzhidai_noBG-Pearl.otf");
}
:root {
--tcl-red: #e2001a;
}
body, html {
padding: 0;
margin: 0;
}
body {
background: black;
height: 100vh;
width: 100vw;
box-sizing: border-box;
overflow: hidden;
}
#gavle-tv {
width: 100%;
height: 100%;
}
.thinking {
position: absolute;
top: 0;
left: 0;
z-index: 10;
width: 200px;
height: 200px;
background: white;
border: white solid 4px;
box-shadow: 8px 8px 0 black;
}
.thinking iframe {
width: 200%;
height: 200%;
transform-origin: 0 0;
transform: scale(0.5);
border: none;
opacity: 0.5;
}
.thinking img {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.5;
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;
position: absolute;
top: 0;
left: 0;
width: 100%;
background: var(--tcl-red);
border: none;
z-index: 100;
transform: translateY(0);
transition: ease-out 0.8s transform;
& > * {
grid-column: 2;
margin: 0;
}
& > h1 {
font-size: 1.3em;
}
& > img:first-child {
grid-column: 1;
grid-row: 1;
height: 1.3em;
align-self: center;
}
}
#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;
color: white;
& > * {
background: var(--tcl-red);
padding: 15px;
margin: 0;
margin-left: auto;
}
& .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
}
}
#next-line ~ #last-passage {
transition: color 0.5s steps(5);
& img {
transition: opacity 0.5s steps(5);
}
}
@keyframes deux-points {
0% { opacity: 1; }
25% { opacity: 1; }
25.1% { opacity: 0; }
100% { opacity: 0; }
}
#clock {
display: block;
font-family: "bianzhidai nobg pearl";
position: fixed;
bottom: 5px;
left: 15px;
font-size: 17vh;
z-index: 1000;
color: white;
mix-blend-mode: exclusion;
line-height: 0.8;
.deux-points {
animation: deux-points steps(4) 1s infinite;
}
}
</style>
</head>
<body>
<iframe id="gavle-tv" src="./liquid-shape-distortions/index.html" frameborder="0"></iframe>
<script src="index.js" type="module"></script>
<div id="next-line" hidden>
<img class="line-picto" src="" />
<h1 class="line-name"></h1>
<p>
<span class="stop-name"></span>:
prochain départ <span class="stop-passage-time-relative"></span>
(<time class="stop-passage-time"></time>)
</p>
</div>
<div id="last-passage" style="width: 100%;">
<section style="max-width: 350px;">
Quelques petites choses a faire avant de partir&nbsp;:
<ul>
<li><strong>Ranger</strong> les établis et les machines</li>
<li>Faire un petit coup de <strong>vaisselle</strong></li>
</ul>
Bonne nuit <strong>:)</strong>
</section>
<template class="last-passage-template">
<article class="last-passage">
<img class="line-picto" src="" />
<h1>
<span class="line-name" ></span>
<small class="stop-name"></small>
</h1>
<p>
Dernier passage dans <span class="stop-passage-time-relative"></span>
(<time class="stop-passage-time"></time>)
</p>
</article>
</template>
</div>
<script type="module" src="./components/clock.js"></script>
<gavle-clock id="clock"></gavle-clock>
</body>
</html>
+185
View File
@@ -0,0 +1,185 @@
import { getNextPassage } from "./tcl.js";
const MIN_TIME_BETWEEN_DAYS = 2 * 60 * 60000 // 2 hours
const MAX_LAST_PASSAGE_TIME = 30 * 60000 // 30 min
const MAX_DERNIER_METRO_MESSAGE = 3
let tracked_stops = [
"line:SYTNEX:A/forward/stop_point:SYTNEX:46052", // Metro A dir Vaulx, Hotel de ville
"line:SYTNEX:A/backward/stop_point:SYTNEX:42743", // Metro A dir Perrache, Hotel de ville
"line:SYTNEX:9/forward/stop_point:SYTNEX:2494", // Bus 9 dir Satoney, Pont de lattre RD
"line:SYTNEX:9/backward/stop_point:SYTNEX:2494", // Bus 9 dir cordeliers, Pont de lattre RD
"line:SYTNEX:C/forward/stop_point:SYTNEX:10787", // Métro C dir Cuire, Croix-Paquet
"line:SYTNEX:C6/forward/stop_point:SYTNEX:2496", // Bus C6 arret Pont de lattre RD campus lyon ouest
"line:SYTNEX:C6/backward/stop_point:SYTNEX:2495", // Bus C6 arret Pont de lattre RD Gare part dieu v. merle
"line:SYTNEX:C13/forward/stop_point:SYTNEX:10169", // Bus C13 arret Austerlitz montessuy gutemberg
"line:SYTNEX:C13/backward/stop_point:SYTNEX:10169", // Bus C13 arret Austerlitz vers grange blanche
"line:SYTNEX:PL2/backward/stop_point:SYTNEX:10889", // Bus PL2 arret Pont de lattre RD vers Musée des confluences
"line:SYTNEX:PL2/forward/stop_point:SYTNEX:10169", // BUS PL2 arret Austerlitz vers Cuire
"line:SYTNEX:C23/backward/stop_point:SYTNEX:10889", // Bus C13 arret Pont de lattre RD vers Flachet - Alain Gilles
"line:SYTNEX:C23/forward/stop_point:SYTNEX:10889", // Bus C13 arret Pont de lattre RD vers Cite internationale
]
let stops_cache = {}
async function shuffleStops(){
tracked_stops = tracked_stops
.map(value => ({ value, sort: Math.random() }))
.sort((a, b) => a.sort - b.sort)
.map(({ value }) => value)
}
async function getNextStop(){
let stop = tracked_stops.shift()
tracked_stops.push(stop)
return stop
}
async function showNextLine(){
let stop = await getNextStop()
let nextPassage = await getNextPassage(stop)
stops_cache[stop] = nextPassage
let time = nextPassage.time
if(!time){
time = nextPassage.line.timetable.find(it => it.time.getTime() > Date.now())?.time
}
if(!time){
return
}
let modal = document.getElementById("next-line")
modal.hidden = false
modal.querySelector(".line-picto").src = nextPassage.line.picto
modal.querySelector(".line-name").textContent = nextPassage.line.displayName
modal.querySelector(".stop-name").textContent = nextPassage.displayName
let timeFormatter = new Intl.DateTimeFormat("fr-FR", {
timeStyle: "short"
});
let timeEl = modal.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"
)
}
modal.querySelector(".stop-passage-time-relative").textContent = relative_time_str
setTimeout(() => modal.hidden = true, 15000)
}
function getLastPassageTime(stopOrLine){
let timetable = stopOrLine.timetable || stopOrLine.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
}
async function updateLastMetro(){
let last_passage_list = Object.values(stops_cache)
.map(it => [it, getLastPassageTime(it)])
.filter(([_, lastPassage]) => lastPassage && (lastPassage.time.getTime() - Date.now()) < MAX_LAST_PASSAGE_TIME)
let hidden_offset = 0
let last_hidden = true
for(let el of document.querySelectorAll("#last-passage > .last-passage")){
if(last_hidden && el.hidden){
hidden_offset++
} else {
last_hidden = false
}
el.remove()
}
let container = document.getElementById("last-passage")
let template = document.querySelector("#last-passage template.last-passage-template");
if(hidden_offset >= last_passage_list.length){
hidden_offset = 0
}
if(last_passage_list.length > 0){
let i = 0
for(let [stop, last_passage] of last_passage_list){
let el = template.content.cloneNode(true)
el.children[0].hidden = i < hidden_offset || i >= hidden_offset + MAX_DERNIER_METRO_MESSAGE;
el.querySelector(".line-picto").src = stop.line.picto
el.querySelector(".line-name").textContent = stop.line.displayName
el.querySelector(".stop-name").textContent = stop.displayName
let timeFormatter = new Intl.DateTimeFormat("fr-FR", {
timeStyle: "short"
});
let timeEl = el.querySelector(".stop-passage-time")
timeEl.textContent = timeFormatter.format(last_passage.time)
timeEl.datetime = last_passage.time.toISOString()
let relativeTimeFormatter = new Intl.RelativeTimeFormat("fr-FR", {
style: "short",
numeric: "auto"
});
el.querySelector(".stop-passage-time-relative").textContent = relativeTimeFormatter.format(
Math.floor((last_passage.time.getTime() - Date.now()) / 60000),
"minutes"
)
container.prepend(el)
i++;
}
container.hidden = false
} else {
container.hidden = true
}
}
shuffleStops()
.then(() => showNextLine())
.then(() => updateLastMetro())
setInterval(() => showNextLine(), 17000)
setInterval(() => updateLastMetro(), 60000)
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 880 KiB

+189
View File
@@ -0,0 +1,189 @@
const BASE_MAP_URL = new URL("/public-transport/lines/", document.baseURI)
const DAY_IN_MS = 8.64e+7;
async function waitForElement(iframe, selector, options = {
timeout: 15000,
multiple: false
}){
let timeout = options?.timeout || 15000
let multiple = options?.multiple || false
let el = null;
let startDate = Date.now()
if(multiple){
while(!(
el = iframe.contentWindow.document.querySelectorAll(selector)
)?.length){
await new Promise(res => setTimeout(res, 500))
if(Date.now() > startDate+timeout) {
throw new Error("Element search timeout")
}
}
} else {
while((
el = iframe.contentWindow.document.querySelector(selector)
) == null){
await new Promise(res => setTimeout(res, 500))
if(Date.now() > startDate+timeout) {
throw new Error("Element search timeout")
}
}
}
return el
}
async function spawnIframe(url){
/** @type {HTMLIFrameElement} */
let iframe = document.createElement("iframe")
let container = document.createElement("div")
container.classList.add("thinking")
container.style.left = Math.round(Math.random()*(window.innerWidth-400))+"px"
container.style.top = Math.round(Math.random()*(window.innerHeight-350))+"px"
let img = document.createElement("img")
img.src = "/lol/math.gif"
container.append(img)
container.append(iframe)
document.body.append(container)
await navigateIframe(iframe, url)
return iframe
}
async function navigateIframe(iframe, url){
let prom = new Promise(res => iframe.addEventListener("load", res(), {once: true}))
iframe.src = new URL(url, BASE_MAP_URL).toString()
await prom
return iframe
}
async function getLineDetails(iframe){
let lineElement = await waitForElement(iframe, `[class|="content"] [class|="pictoAndDirection"]`)
let linePictoElement = await waitForElement(iframe, `[class|="content"] [class|="linePictoSvg"]`)
let directionTextElement = await waitForElement(iframe, `[class|="content"] [class|="directionText"]`)
let line = {
displayName: lineElement.getAttribute("aria-label"),
picto: linePictoElement.src,
name: linePictoElement.getAttribute("aria-label"),
direction: directionTextElement.innerText,
timetable: null
}
return line
}
async function getStopTimeTable(iframe, stop_description, amount=1){
let timetable = []
for(let dayOffset = 0; dayOffset<amount; dayOffset++){
let now = new Date(Date.now()+(dayOffset*DAY_IN_MS))
await navigateIframe(iframe, "./"+stop_description+`/timetable?date=${now.getFullYear()}-${(now.getMonth()+1).toString().padStart(2, "0")}-${(now.getDate()).toString().padStart(2, "0")}`)
let timeTableElList = await waitForElement(iframe, `[class|="content"] [class|="timeTableContainer"] table`, {multiple: true});
for(let timeTableEl of timeTableElList){
let trancheHoraires = timeTableEl.querySelectorAll("thead tr:first-child th")
for(let i = 0; i< trancheHoraires.length; i++){
let el = trancheHoraires[i]
let hours = parseInt(el.innerText.trim())
if(Number.isFinite(hours)){
let allHoraires = timeTableEl.querySelectorAll(`tbody tr td:nth-child(${i+1})`)
for(let hel of allHoraires){
let minutes = parseInt(hel.innerText.trim())
if(Number.isFinite(minutes)){
timetable.push({
displayHours: el.innerText,
displayMinutes: hel.innerText,
displayTime: `${el.innerText}${hel.innerText}`,
time: new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
hours,
minutes
)
})
}
}
}
}
}
}
return timetable
}
/**
* Get next Bus/Metro/Tram passage of given stop description
* @param {String} stop_description Stop description string
*
* To get stop description go to https://carte-interactive.tcl.fr/public-transport/lines/
* then find your line and your stop in this line.
* Your URL must look like something like this
* https://carte-interactive.tcl.fr/public-transport/lines/line:SYTNEX:C/forward/stop_point:SYTNEX:10787
* Stop description is everything after "lines/" ("line:SYTNEX:C/forward/stop_point:SYTNEX:10787" in example above)
*/
export async function getNextPassage(stop_description, options = {
timetable: +2 // Today and tomorrow
}){
let iframe = await spawnIframe("./"+stop_description)
let nextPassageElement = await waitForElement(iframe, `[class|="content"] [class|="stopCard"] [class|="schedule"]`);
let stopName = await waitForElement(iframe, `[class|="content"] [class|="stopCard"] [class|="nameStop"]`);
let line = await getLineDetails(iframe, stop_description)
let timetable = []
let timeTableAmount = options?.timetable || +2;
if(timeTableAmount){
timetable = await getStopTimeTable(iframe, stop_description, timeTableAmount)
} else {
timetable = null
}
line.timetable = timetable
let nextPassage = {
line,
displayName: stopName.innerText,
displayTime: nextPassageElement.innerText
}
{
let timeText = nextPassageElement.innerText.trim();
let time;
if(timeText.endsWith("min")){
time = new Date(Date.now() + parseInt(timeText)*60000)
} else {
let match = timeText.match(/^(\d+):(\d+)/)
if(match){
let now = new Date()
time = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
parseInt(match[1]),
parseInt(match[2])
)
if(time.getTime() < Date.now()){
time.setTime(time.getTime() + DAY_IN_MS)
}
}
}
nextPassage.time = time
}
setTimeout(() => {
iframe.parentElement.remove()
}, Math.random()*1000)
return nextPassage
}
+164
View File
@@ -0,0 +1,164 @@
const DAY_IN_MS = 8.64e+7;
class StopId {
constructor(stopIdString){
let [lineId, direction, stopId] = stopIdString.split(/\//)
this.lineId = lineId
this.stopId = stopId
this.direction = direction
}
}
async function getNextTripJSON(stop_id){
let res = await fetch(`/api/interface/tcl/next-trips/stops/${encodeURIComponent(stop_id.stopId)}/${encodeURIComponent(stop_id.lineId)}/${stop_id.direction}`);
if(!res.ok){
throw new Error(`Server responded with ${res.status} ${res.statusText}`)
}
let result = await res.json();
if(!result.data){
throw new Error(`Stop ${stop_id.stopId} not found`)
}
return result.data[0]
}
async function getStopsJSON(stop_id){
let res = await fetch(`/api/interface/tcl/lines/${encodeURIComponent(stop_id.lineId)}/stops`);
if(!res.ok){
throw new Error(`Server responded with ${res.status} ${res.statusText}`)
}
let result = await res.json();
if(!result.data){
throw new Error(`Line ${stop_id.lineId} not found`)
}
return result.data
}
async function getTimetableJSON(stop_id, date){
let res = await fetch(`/api/interface/tcl/timetables/${encodeURIComponent(stop_id.stopId)}/${encodeURIComponent(stop_id.lineId)}/${stop_id.direction}?date=${date.getFullYear()}-${(date.getMonth()+1).toString().padStart(2, "0")}-${date.getDate().toString().padStart(2, "0")}`);
if(!res.ok){
throw new Error(`Server responded with ${res.status} ${res.statusText}`)
}
let result = await res.json();
if(!result.data){
throw new Error(`Timetable for ${stop_id.lineId} not found`)
}
return result.data
}
async function getLineDetailsJSON(stop_id){
let res = await fetch(`/api/interface/tcl/lines/${encodeURIComponent(stop_id.lineId)}`);
if(!res.ok){
throw new Error(`Server responded with ${res.status} ${res.statusText}`)
}
let result = await res.json();
if(!result.data){
throw new Error(`Line ${stop_id.lineId} not found`)
}
return result.data
}
function getIconURL(stop_id){
return new URL(`/api/valkyrie/assets/lines/${stop_id.lineId}.svg?type=image%2Fsvg%2Bxml`, window.location).toString()
}
async function getLineDetails(stop_id){
let line_json = await getLineDetailsJSON(stop_id)
let route = line_json.routes.find(it => it.direction == stop_id.direction)
let line = {
id: stop_id.lineId,
displayName: route?.name,
picto: getIconURL(stop_id),
name: line_json.code,
direction: route?.name,
timetable: null
}
return line
}
async function getTimetable(stop_id, date){
let timetable_json = await getTimetableJSON(stop_id, date)
let timetable = []
for(let scheduled_time of timetable_json.scheduleTimes){
let time = new Date(scheduled_time.dateTime)
timetable.push({
displayHours: time.getHours().toString().padStart(2, "0"),
displayMinutes: time.getMinutes().toString().padStart(2, "0"),
displayTime: `${time.getHours().toString().padStart(2, "0")}h${time.getMinutes().toString().padStart(2, "0")}`,
time
})
}
return timetable
}
/**
* Get next Bus/Metro/Tram passage of given stop description
* @param {String} stop_description Stop description string
*
* To get stop description go to https://carte-interactive.tcl.fr/public-transport/lines/
* then find your line and your stop in this line.
* Your URL must look like something like this
* https://carte-interactive.tcl.fr/public-transport/lines/line:SYTNEX:C/forward/stop_point:SYTNEX:10787
* Stop description is everything after "lines/" ("line:SYTNEX:C/forward/stop_point:SYTNEX:10787" in example above)
*/
export async function getNextPassage(stop_description, options = {
timetable: +2 // Today and tomorrow
}){
let stopId = new StopId(stop_description);
let proms = [
getLineDetails(stopId),
getNextTripJSON(stopId),
getStopsJSON(stopId),
Promise.resolve([])
]
let timeTableAmount = options?.timetable || +2;
if(timeTableAmount){
for(let dayOffset = 0; dayOffset<timeTableAmount; dayOffset++){
let now = new Date(Date.now()+(dayOffset*DAY_IN_MS))
proms.push(await getTimetable(stopId, now))
}
}
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
let stop = line_stops.find(it => it.id == stopId.stopId)
let nextPassage = {
line,
displayName: stop?.name,
displayTime: "<unsupported>",
time: next_trip.schedules?.[0] ? new Date(next_trip.schedules?.[0].dateTime) : null
}
return nextPassage
}
+44
View File
@@ -0,0 +1,44 @@
daemon off;
#error_log /tmp/gavle-error.log;
pid /dev/null;
include /etc/nginx/modules-enabled/*.conf;
events {
worker_connections 768;
}
http {
sendfile on;
tcp_nopush on;
types_hash_max_size 2048;
resolver 10.0.0.2 [fd45:ae2e:e260:101::] valid=30s;
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
server {
listen 8000 ssl default_server;
ssl_certificate /etc/ssl/certs/ssl-cert-snakeoil.pem;
ssl_certificate_key /etc/ssl/private/ssl-cert-snakeoil.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
root /home/lol/dernier-metro;
location / {
proxy_ssl_server_name on;
proxy_buffering off;
proxy_pass https://carte-interactive.tcl.fr/;
}
location /lol {
root /home/lol/dernier-metro;
}
}
}
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
sudo systemctl restart getty@tty1.service