Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion custom_components/beatify/www/css/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -12645,7 +12645,31 @@ body.is-ingame .player-header { display: none; }
transform: translate(-50%, -50%);
}
.slider-arcade { gap: 8px; } /* tighten gap so 5 children fit on narrow screens */
.slider-arcade-track { flex: 1; display: flex; align-items: center; }
/* #2344: the track holds the input and the decade scale stacked, so the
marks line up with the thumb's travel rather than with the box. */
.slider-arcade-track { flex: 1; display: flex; flex-direction: column; align-items: stretch; gap: 4px; }
.year-scale {
position: relative;
height: 13px;
/* Reserve the thumb's half-width on both sides: a range thumb's centre
never reaches the very edge, so a mark at 100% would sit past the
furthest year the slider can actually select. */
margin: 0 2px;
pointer-events: none;
}
.year-scale span {
position: absolute;
top: 0;
transform: translateX(-50%);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--color-text-muted, rgba(255, 255, 255, 0.42));
font-variant-numeric: tabular-nums;
white-space: nowrap;
user-select: none;
}
.slider-arcade.slider-arcade--locked .year-scale { opacity: 0.5; }
.year-slider-arcade {
-webkit-appearance: none;
appearance: none;
Expand Down
2 changes: 1 addition & 1 deletion custom_components/beatify/www/css/styles.min.css

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions custom_components/beatify/www/css/styles.min.css.map

Large diffs are not rendered by default.

68 changes: 68 additions & 0 deletions custom_components/beatify/www/js/player-game.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,70 @@ var lastLeaderboard = [];
* still happen the other way: a game whose range shrinks between rounds
* would otherwise leave the thumb parked outside its own track.
*/
/**
* #2344: decade marks under the year slider.
*
* The track carried no landmarks at all — 76 years of blank rail. The cost is
* not precision (a thumb-width of travel is about two years, not thirty) but
* orientation: there was no way to see where 1985 sits, so the interaction was
* drag, read the number, drag again, with twelve seconds on the clock.
*
* Derived from the live span rather than pinned to percentages, because #2337
* made the bounds move: applyYearRange() sets min/max from the playlist in
* play, and a mark nailed to a fixed position would drift the moment a
* playlist reaches past the default.
*
* Two details that are easy to get wrong:
*
* A range thumb's centre travels from thumbWidth/2 to width - thumbWidth/2,
* never to the very edge, so positions are laid out inside that inset — a mark
* at a true 100% would sit past the furthest year the slider can select.
*
* And the step widens on long spans. Eight labels on a phone track collide;
* the rule below keeps at most eight, which is where a 10px label still has
* clear air around it on a ~300px track.
*/
var YEAR_SCALE_THUMB_PX = 32;
var YEAR_SCALE_MAX_MARKS = 8;

export function renderYearScale(lo, hi) {
var scale = document.getElementById('year-scale');
if (!scale) return;

scale.textContent = '';
if (!isFinite(lo) || !isFinite(hi) || hi <= lo) return;

// Widen from decades to 20- or 50-year steps rather than letting labels
// pile up on a narrow track.
var step = 10;
while ((hi - lo) / step > YEAR_SCALE_MAX_MARKS) {
step = step === 10 ? 20 : step * 2.5;
}

var half = YEAR_SCALE_THUMB_PX / 2;
var first = Math.ceil(lo / step) * step;

var years = [];
for (var y = first; y <= hi; y += step) years.push(y);

// Two digits with an apostrophe: language-neutral, so this needs no
// translation, and narrow enough that eight fit on a phone. But a span
// crossing a century renders '00 twice — 1900 and 2000 collide — so the
// short form is only used while it stays unambiguous.
var short = years.map(function (v) { return v % 100; });
var ambiguous = short.some(function (v, i) { return short.indexOf(v) !== i; });

years.forEach(function (year) {
var pct = (year - lo) / (hi - lo);
var mark = document.createElement('span');
mark.textContent = ambiguous
? String(year)
: "'" + String(year % 100).padStart(2, '0');
mark.style.left = 'calc(' + half + 'px + ' + pct + ' * (100% - ' + YEAR_SCALE_THUMB_PX + 'px))';
scale.appendChild(mark);
});
}

export function applyYearRange(range) {
var slider = document.getElementById('year-slider');
if (!slider || !range) return;
Expand All @@ -97,6 +161,10 @@ export function applyYearRange(range) {
var yearDisplay = document.getElementById('selected-year');
if (yearDisplay) yearDisplay.textContent = String(clamped);
}

// #2344: the scale is derived from the same span, so it is rebuilt here
// and nowhere else — one source for the bounds, one for the marks.
renderYearScale(lo, hi);
}

/**
Expand Down
8 changes: 4 additions & 4 deletions custom_components/beatify/www/js/player.bundle.min.js

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions custom_components/beatify/www/player.html
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,12 @@ <h2 class="section-header section-header--inline">
max="2025"
value="1990"
class="year-slider year-slider-arcade">
<!-- #2344: decade marks, built by renderYearScale()
from the same span applyYearRange() applies.
Empty here on purpose — pinning them to fixed
percentages would go wrong the moment the
bounds move, which they now do. -->
<div id="year-scale" class="year-scale" aria-hidden="true"></div>
</div>
<button type="button" id="year-increment" class="slider-btn-year" aria-label="Increase year">+</button>
<button type="button" id="year-increment-5" class="slider-btn-year slider-btn-year--coarse" aria-label="Increase year by 5">+5</button>
Expand Down
114 changes: 114 additions & 0 deletions tests/unit/test_year_scale_marks_2344.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""Der Jahres-Regler braucht Landmarken (#2344).

Die Bahn trug **keine einzige Markierung** — 76 Jahre blankes Gleis. Die
Schwierigkeit ist dabei nicht die Praezision: bei 1950–2026 auf 300 px sind es
**0,25 Jahre pro Pixel**, eine Daumenbewegung von 8 px also rund **zwei Jahre**.
(Die Ausgangsbeschreibung nannte vier Jahre pro Pixel — Faktor sechzehn daneben,
im Issue korrigiert.)

Das Problem ist die **Orientierung**: man sieht nicht, wo 1985 liegt, also
zieht man, liest die Zahl, zieht nach — bei zwoelf Sekunden auf der Uhr.

**Abgeleitet statt festgenagelt.** Seit #2337 setzt `applyYearRange()` die
Grenzen aus der laufenden Playlist. Marken auf feste Prozentwerte zu legen
waere in dem Moment falsch, in dem eine Playlist ueber den Standard
hinausreicht — deshalb rechnet die Skala aus derselben Spanne.
"""

from __future__ import annotations

import re
from pathlib import Path

_JS = (
Path(__file__).resolve().parents[2]
/ "custom_components"
/ "beatify"
/ "www"
/ "js"
/ "player-game.js"
)
_HTML = (
Path(__file__).resolve().parents[2]
/ "custom_components"
/ "beatify"
/ "www"
/ "player.html"
)


def _src() -> str:
return _JS.read_text()


def _fn(name: str) -> str:
src = _src()
start = src.index(f"function {name}(")
nxt = re.search(r"\n(?:export )?function ", src[start + 10 :])
return src[start : start + 10 + nxt.start()] if nxt else src[start:]


def _step_for(lo: int, hi: int, max_marks: int = 8) -> float:
"""Die Schrittweiten-Regel aus dem JS, unabhaengig nachgebildet."""
step: float = 10
while (hi - lo) / step > max_marks:
step = 20 if step == 10 else step * 2.5
return step


def _marks(lo: int, hi: int) -> list[int]:
step = _step_for(lo, hi)
first = -(-lo // step) * step
out, y = [], first
while y <= hi:
out.append(int(y))
y += step
return out


class TestTheMarksFollowTheSpan:
def test_the_default_span_gets_its_decades(self):
# 1950–2026 ist der Standardfall nach #2337. Acht Jahrzehnte, auf
# einer 300-px-Bahn rund 35 px auseinander.
assert _marks(1950, 2026) == [1950, 1960, 1970, 1980, 1990, 2000, 2010, 2020]

def test_a_narrow_playlist_gets_fewer_marks(self):
# Eine enge Playlist bekommt keine erfundenen Landmarken.
assert _marks(1980, 1995) == [1980, 1990]

def test_a_long_span_widens_the_step(self):
# Acht Marken sind die Grenze; darueber wird der Schritt groesser,
# statt die Beschriftungen aufeinanderzuschieben.
assert _step_for(1927, 2026) == 20
assert len(_marks(1927, 2026)) <= 8


class TestTheTwoThingsEasyToGetWrong:
def test_positions_are_inset_by_half_the_thumb(self):
# Der Daumen-Mittelpunkt erreicht den Rand nie. Eine Marke bei echten
# 100 % saesse hinter dem hoechsten waehlbaren Jahr.
body = _fn("renderYearScale")
assert "YEAR_SCALE_THUMB_PX / 2" in body
assert "(100% - " in body

def test_a_century_crossing_falls_back_to_four_digits(self):
# 1900 und 2000 waeren beide "'00". Die Kurzform gilt nur, solange
# sie eindeutig bleibt.
body = _fn("renderYearScale")
assert "ambiguous" in body
short = [y % 100 for y in _marks(1900, 2026)]
assert len(short) != len(set(short)), "der Testfall selbst muss kollidieren"


class TestItIsWiredToTheOneSourceOfTheSpan:
def test_applyyearrange_rebuilds_the_scale(self):
# Grenzen und Marken duerfen nicht aus zwei Quellen kommen.
assert "renderYearScale(lo, hi)" in _fn("applyYearRange")

def test_the_markup_ships_an_empty_container(self):
# Feste Marken im HTML waeren genau der Fehler, den #2337 gerade
# beseitigt hat.
html = _HTML.read_text()
assert 'id="year-scale"' in html
m = re.search(r'<div id="year-scale"[^>]*>(.*?)</div>', html, re.DOTALL)
assert m and not m.group(1).strip()
Loading