Skip to content

Commit 488083f

Browse files
JaeRyoungKaclaude
andcommitted
feat: paginate notice ticker by browser width instead of per sentence
Sentence-per-page left wide screens underfilled. Pack each notice's text into width-fitting pages, measured against the ticker's actual rendered width via canvas measureText, so a notice fills one line on desktop and only splits into rotating pages when it overflows on narrow screens. Repaginate on resize (debounced 150ms). Falls back to title + sentence splitting when the width can't be measured. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f0079d4 commit 488083f

3 files changed

Lines changed: 79 additions & 16 deletions

File tree

src/main.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
toIsoDate
1111
} from "./utils/time.js";
1212
import { resolveServiceDay, getResolvedDay } from "./utils/serviceDay.js";
13-
import { buildNoticePages } from "./utils/noticeTicker.js";
13+
import { buildNoticePages, createTickerFit } from "./utils/noticeTicker.js";
1414
import { getStopShortName } from "./utils/stopLabels.js";
1515
import { escapeHtml, matchesQuery, highlightMatch } from "./utils/search.js";
1616
import { getCompositionFallback } from "./utils/imeSearch.js";
@@ -175,6 +175,8 @@ let noticeTickerTimer = null;
175175
let noticeTickerIndex = 0;
176176
let noticeTickerFadeTimer = null;
177177
let noticeTickerSwapTimer = null;
178+
let noticeTickerResizeBound = false;
179+
let noticeTickerResizeTimer = null;
178180

179181
function localizeRoute(route) {
180182
return getLocalizedText(route, state.locale, "name");
@@ -723,6 +725,13 @@ function renderNotices() {
723725
function renderNoticeTicker() {
724726
const ticker = document.querySelector("#noticeTicker");
725727
if (!ticker) return;
728+
if (!noticeTickerResizeBound) {
729+
noticeTickerResizeBound = true;
730+
window.addEventListener("resize", () => {
731+
clearTimeout(noticeTickerResizeTimer);
732+
noticeTickerResizeTimer = setTimeout(renderNoticeTicker, 150);
733+
});
734+
}
726735
const flashNoticeSection = () => {
727736
const target = document.querySelector("#notice");
728737
if (!target) return;
@@ -770,7 +779,7 @@ function renderNoticeTicker() {
770779
return;
771780
}
772781

773-
const pages = buildNoticePages(visible, state.locale === LOCALES.EN);
782+
const pages = buildNoticePages(visible, state.locale === LOCALES.EN, createTickerFit(ticker));
774783

775784
const goNoticeSection = () => {
776785
const target = document.querySelector("#notice");

src/route.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
toIsoDate
1111
} from "./utils/time.js";
1212
import { getResolvedDay } from "./utils/serviceDay.js";
13-
import { buildNoticePages } from "./utils/noticeTicker.js";
13+
import { buildNoticePages, createTickerFit } from "./utils/noticeTicker.js";
1414
import { getStopShortName } from "./utils/stopLabels.js";
1515
import { escapeHtml, matchesQuery, highlightMatch } from "./utils/search.js";
1616
import { getCompositionFallback } from "./utils/imeSearch.js";
@@ -66,6 +66,8 @@ let noticeTickerTimer = null;
6666
let noticeTickerIndex = 0;
6767
let noticeTickerFadeTimer = null;
6868
let noticeTickerSwapTimer = null;
69+
let noticeTickerResizeBound = false;
70+
let noticeTickerResizeTimer = null;
6971

7072
function localizeRoute(route) {
7173
return getLocalizedText(route, state.locale, "name");
@@ -101,6 +103,14 @@ function renderNoticeTicker() {
101103
const ticker = document.querySelector("#noticeTicker");
102104
if (!ticker) return;
103105

106+
if (!noticeTickerResizeBound) {
107+
noticeTickerResizeBound = true;
108+
window.addEventListener("resize", () => {
109+
clearTimeout(noticeTickerResizeTimer);
110+
noticeTickerResizeTimer = setTimeout(renderNoticeTicker, 150);
111+
});
112+
}
113+
104114
if (noticeTickerTimer) {
105115
clearInterval(noticeTickerTimer);
106116
noticeTickerTimer = null;
@@ -138,7 +148,7 @@ function renderNoticeTicker() {
138148
return;
139149
}
140150

141-
const pages = buildNoticePages(visible, state.locale === LOCALES.EN);
151+
const pages = buildNoticePages(visible, state.locale === LOCALES.EN, createTickerFit(ticker));
142152

143153
const draw = (withFade = false) => {
144154
const text = pages[noticeTickerIndex % pages.length];

src/utils/noticeTicker.js

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,65 @@
11
// 상단 공지 티커에서 쓸 '페이지' 배열을 만든다.
2-
// 각 공지는 "[태그] 제목" 페이지 + 본문을 문장 단위로 나눈 페이지들로 분해된다.
3-
// 티커는 이 페이지들을 일정 시간마다 한 줄씩 위로 올려 보여주므로,
4-
// 공지가 길어도 잘리지 않고 문장이 순서대로 전부 노출된다.
5-
// 짧은 공지(본문 없음)는 제목 한 페이지로 끝난다.
6-
export function buildNoticePages(notices, isEn) {
2+
//
3+
// fit(측정 정보)이 주어지면 각 공지 텍스트를 브라우저에서 실제로 보이는 폭에 맞춰
4+
// 단어 단위로 최대한 채워 끊는다. 넓은 화면에서는 공지 하나가 한 페이지에 다 들어가고,
5+
// 좁은 화면에서는 여러 페이지로 나뉘어 순환한다.
6+
// fit이 없으면(측정 불가) 제목 + 본문 문장 단위로 폴백한다.
7+
export function buildNoticePages(notices, isEn, fit = null) {
78
const pages = [];
89
for (const notice of notices) {
910
const tag = isEn ? notice.tagEn || notice.tag || "Notice" : notice.tag || "공지";
1011
const title = isEn ? notice.titleEn || notice.title : notice.title;
1112
const body = isEn ? notice.bodyEn || notice.body || "" : notice.body || "";
12-
pages.push(`[${tag}] ${title}`);
13-
// 문장 끝(. ! ? 。) 뒤 공백을 기준으로 본문을 문장 단위로 분해.
14-
const sentences = body
15-
.split(/(?<=[.!?])\s+/)
16-
.map((s) => s.trim())
17-
.filter(Boolean);
18-
for (const sentence of sentences) pages.push(sentence);
13+
const full = `[${tag}] ${title}${body ? ` - ${body}` : ""}`.trim();
14+
if (fit && fit.availWidth > 0 && typeof fit.measure === "function") {
15+
wrapToWidth(full, fit, pages);
16+
} else {
17+
pages.push(`[${tag}] ${title}`);
18+
for (const sentence of splitSentences(body)) pages.push(sentence);
19+
}
1920
}
2021
return pages;
2122
}
23+
24+
// 문장 끝(. ! ? 。) 뒤 공백을 기준으로 본문을 문장 단위로 분해 (폴백용).
25+
function splitSentences(body) {
26+
return body
27+
.split(/(?<=[.!?])\s+/)
28+
.map((s) => s.trim())
29+
.filter(Boolean);
30+
}
31+
32+
// 단어를 순서대로 채우다 availWidth를 넘기면 새 페이지로 넘긴다.
33+
function wrapToWidth(text, fit, out) {
34+
const words = text.split(/\s+/).filter(Boolean);
35+
let line = "";
36+
for (const word of words) {
37+
const candidate = line ? `${line} ${word}` : word;
38+
if (line && fit.measure(candidate) > fit.availWidth) {
39+
out.push(line);
40+
line = word;
41+
} else {
42+
line = candidate;
43+
}
44+
}
45+
if (line) out.push(line);
46+
}
47+
48+
// 티커 엘리먼트의 현재 렌더 폭·폰트를 재서 buildNoticePages에 넘길 fit 객체를 만든다.
49+
// 폭을 못 재면(숨김/미마운트) null 을 반환해 문장 단위 폴백을 타게 한다.
50+
export function createTickerFit(el) {
51+
if (!el || typeof document === "undefined" || typeof getComputedStyle !== "function") return null;
52+
const cs = getComputedStyle(el);
53+
const availWidth =
54+
el.clientWidth -
55+
parseFloat(cs.paddingLeft || "0") -
56+
parseFloat(cs.paddingRight || "0") -
57+
2; // 소수점/자간 오차 여유
58+
if (!(availWidth > 0)) return null;
59+
const canvas =
60+
createTickerFit._canvas || (createTickerFit._canvas = document.createElement("canvas"));
61+
const ctx = canvas.getContext("2d");
62+
if (!ctx) return null;
63+
ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
64+
return { availWidth, measure: (text) => ctx.measureText(text).width };
65+
}

0 commit comments

Comments
 (0)