PageSourceSearch

https://djsresearch.co.uk/static/js/report-card-text-clamp.js

js djsresearch.co.uk collected 2026-09-24 09:24:00 UTC 18,204 bytes, 560 lines download raw bytes

1/**
2 * Report card text truncation.
3 *
4 * Rules:
5 * - Title 1 line  -> description 4 lines
6 * - Title 2 lines -> description 3 lines
7 * - Title 3 lines -> description 2 lines
8 * - Title 4+      -> description 1 line
9 *
10 * Implementation details:
11 * - Title line count uses real rendered line boxes (Range.getClientRects).
12 * - Description is truncated destructively by words.
13 * - Candidate text always includes "..." during fitting to prevent a new
14 *   line from being introduced by the ellipsis itself.
15 */
16(function() {
17    const CARD_SELECTOR = '.report-card';
18    const TITLE_SELECTOR = '.report-title';
19    const TITLE_TEXT_SELECTOR = '.report-title span';
20    const BRIEF_SELECTOR = '.report-brief';
21    const ELLIPSIS = '...';
22
23    let isApplying = false;
24    let observer = null;
25
26    function normalizeText(text) {
27        return (text || '').replace(/\s+/g, ' ').trim();
28    }
29
30    function getLineHeight(element) {
31        const computed = window.getComputedStyle(element);
32        const parsed = parseFloat(computed.lineHeight);
33
34        if (!Number.isNaN(parsed)) {
35            return parsed;
36        }
37
38        const fontSize = parseFloat(computed.fontSize) || 16;
39        return fontSize * 1.2;
40    }
41
42    function getContentWidth(element) {
43        if (!element) {
44            return 0;
45        }
46
47        const rect = element.getBoundingClientRect();
48        const styles = window.getComputedStyle(element);
49        const horizontalPadding =
50            (parseFloat(styles.paddingLeft) || 0) +
51            (parseFloat(styles.paddingRight) || 0);
52        const horizontalBorder =
53            (parseFloat(styles.borderLeftWidth) || 0) +
54            (parseFloat(styles.borderRightWidth) || 0);
55
56        return Math.max(0, rect.width - horizontalPadding - horizontalBorder);
57    }
58
59    function createTextProbe(styleSource) {
60        const styles = window.getComputedStyle(styleSource);
61        const probe = document.createElement('span');
62
63        probe.style.position = 'absolute';
64        probe.style.visibility = 'hidden';
65        probe.style.pointerEvents = 'none';
66        probe.style.left = '-99999px';
67        probe.style.top = '0';
68        probe.style.margin = '0';
69        probe.style.padding = '0';
70        probe.style.border = '0';
71        probe.style.boxSizing = 'content-box';
72        probe.style.whiteSpace = 'nowrap';
73        probe.style.letterSpacing = styles.letterSpacing;
74        probe.style.wordSpacing = styles.wordSpacing;
75        probe.style.textTransform = styles.textTransform;
76        probe.style.textIndent = styles.textIndent;
77        probe.style.fontFamily = styles.fontFamily;
78        probe.style.fontSize = styles.fontSize;
79        probe.style.fontWeight = styles.fontWeight;
80        probe.style.fontStyle = styles.fontStyle;
81        probe.style.fontVariant = styles.fontVariant;
82        probe.style.lineHeight = styles.lineHeight;
83
84        return probe;
85    }
86
87    function createMultilineProbe(styleSource, widthPx) {
88        const styles = window.getComputedStyle(styleSource);
89        const probe = document.createElement('div');
90
91        probe.style.position = 'absolute';
92        probe.style.visibility = 'hidden';
93        probe.style.pointerEvents = 'none';
94        probe.style.left = '-99999px';
95        probe.style.top = '0';
96        probe.style.margin = '0';
97        probe.style.padding = '0';
98        probe.style.border = '0';
99        probe.style.boxSizing = 'content-box';
100        probe.style.whiteSpace = 'normal';
101        probe.style.width = Math.floor(widthPx) + 'px';
102        probe.style.overflowWrap = styles.overflowWrap;
103        probe.style.wordBreak = styles.wordBreak;
104        probe.style.letterSpacing = styles.letterSpacing;
105        probe.style.wordSpacing = styles.wordSpacing;
106        probe.style.textTransform = styles.textTransform;
107        probe.style.textIndent = styles.textIndent;
108        probe.style.fontFamily = styles.fontFamily;
109        probe.style.fontSize = styles.fontSize;
110        probe.style.fontWeight = styles.fontWeight;
111        probe.style.fontStyle = styles.fontStyle;
112        probe.style.fontVariant = styles.fontVariant;
113        probe.style.lineHeight = styles.lineHeight;
114
115        return probe;
116    }
117
118    function fitsInLineLimitOffscreen(element, text, maxLines) {
119        const contentWidth = getContentWidth(element);
120
121        if (contentWidth <= 0) {
122            return false;
123        }
124
125        const probe = createMultilineProbe(element, contentWidth);
126        probe.textContent = text;
127        document.body.appendChild(probe);
128        const fits = fitsWithinLineLimit(probe, maxLines);
129        document.body.removeChild(probe);
130
131        return fits;
132    }
133
134    function fitsInSingleLine(element, text) {
135        const contentWidth = getContentWidth(element);
136
137        if (contentWidth <= 0) {
138            return false;
139        }
140
141        const probe = createTextProbe(element);
142        probe.textContent = text;
143        document.body.appendChild(probe);
144        const probeWidth = probe.getBoundingClientRect().width;
145        document.body.removeChild(probe);
146
147        return probeWidth <= contentWidth + 0.5;
148    }
149
150    function countElementLines(element) {
151        if (!element) {
152            return 0;
153        }
154
155        const rect = element.getBoundingClientRect();
156
157        if (rect.width <= 0 || rect.height <= 0) {
158            return 0;
159        }
160
161        try {
162            const range = document.createRange();
163            range.selectNodeContents(element);
164            const rects = Array.from(range.getClientRects());
165
166            if (rects.length > 0) {
167                const tops = [];
168
169                rects.forEach(function(lineRect) {
170                    const roundedTop = Math.round(lineRect.top * 2) / 2;
171                    const alreadyTracked = tops.some(function(top) {
172                        return Math.abs(top - roundedTop) < 0.6;
173                    });
174
175                    if (!alreadyTracked) {
176                        tops.push(roundedTop);
177                    }
178                });
179
180                if (tops.length > 0) {
181                    return tops.length;
182                }
183            }
184        } catch (error) {
185            // Fall through to height/line-height fallback.
186        }
187
188        const lineHeight = getLineHeight(element);
189        if (lineHeight <= 0) {
190            return 0;
191        }
192
193        return Math.max(1, Math.ceil((rect.height + 0.5) / lineHeight));
194    }
195
196    function fitsWithinLineLimit(element, maxLines) {
197        if (!element) {
198            return false;
199        }
200
201        const rect = element.getBoundingClientRect();
202        if (rect.width <= 0 || rect.height <= 0) {
203            return false;
204        }
205
206        const lineHeight = getLineHeight(element);
207        if (lineHeight > 0) {
208            // Small tolerance to absorb sub-pixel rounding differences so
209            // truncation is not overly conservative at line boundaries.
210            const allowedHeight = (maxLines * lineHeight) + 1.5;
211            if (rect.height <= allowedHeight) {
212                return true;
213            }
214        }
215
216        return countElementLines(element) <= maxLines;
217    }
218
219    function countWrappedTextLines(containerElement, styleElement, text) {
220        const content = normalizeText(text);
221
222        if (!containerElement || !styleElement || !content) {
223            return 0;
224        }
225
226        const containerRect = containerElement.getBoundingClientRect();
227        const containerStyles = window.getComputedStyle(containerElement);
228        const styleSource = window.getComputedStyle(styleElement);
229
230        const horizontalPadding =
231            (parseFloat(containerStyles.paddingLeft) || 0) +
232            (parseFloat(containerStyles.paddingRight) || 0);
233        const horizontalBorder =
234            (parseFloat(containerStyles.borderLeftWidth) || 0) +
235            (parseFloat(containerStyles.borderRightWidth) || 0);
236
237        const contentWidth = Math.max(0, containerRect.width - horizontalPadding - horizontalBorder);
238
239        if (contentWidth <= 0) {
240            return 0;
241        }
242
243        const probe = document.createElement('div');
244        probe.textContent = content;
245        probe.style.position = 'absolute';
246        probe.style.visibility = 'hidden';
247        probe.style.pointerEvents = 'none';
248        probe.style.left = '-99999px';
249        probe.style.top = '0';
250        probe.style.margin = '0';
251        probe.style.padding = '0';
252        probe.style.border = '0';
253        probe.style.boxSizing = 'content-box';
254        probe.style.whiteSpace = 'normal';
255        probe.style.width = Math.floor(contentWidth) + 'px';
256        probe.style.overflowWrap = styleSource.overflowWrap;
257        probe.style.wordBreak = styleSource.wordBreak;
258        probe.style.letterSpacing = styleSource.letterSpacing;
259        probe.style.wordSpacing = styleSource.wordSpacing;
260        probe.style.textTransform = styleSource.textTransform;
261        probe.style.textIndent = styleSource.textIndent;
262        probe.style.fontFamily = styleSource.fontFamily;
263        probe.style.fontSize = styleSource.fontSize;
264        probe.style.fontWeight = styleSource.fontWeight;
265        probe.style.fontStyle = styleSource.fontStyle;
266        probe.style.fontVariant = styleSource.fontVariant;
267        probe.style.lineHeight = styleSource.lineHeight;
268
269        document.body.appendChild(probe);
270        const lines = countElementLines(probe);
271        document.body.removeChild(probe);
272
273        return lines;
274    }
275
276    function getTitleLines(card) {
277        const title = card.querySelector(TITLE_SELECTOR);
278        const titleText = card.querySelector(TITLE_TEXT_SELECTOR);
279        if (!title) {
280            return 0;
281        }
282
283        const rawTitleText = titleText ? titleText.textContent : title.textContent;
284        const measured = countWrappedTextLines(title, titleText || title, rawTitleText);
285
286        if (measured > 0) {
287            return measured;
288        }
289
290        const fallback = countElementLines(titleText || title);
291        // When titleText exists, the markup includes a structural <br> before it.
292        // Compensate that extra line in fallback path.
293        return titleText ? Math.max(1, fallback - 1) : fallback;
294    }
295
296    function getDescriptionLinesForTitleLines(titleLines) {
297        return Math.max(1, Math.min(4, 5 - titleLines));
298    }
299
300    function getOriginalBriefText(brief) {
301        if (!brief.dataset.originalText) {
302            brief.dataset.originalText = normalizeText(brief.textContent);
303        }
304
305        return brief.dataset.originalText;
306    }
307
308    function splitWords(text) {
309        return normalizeText(text).split(' ').filter(Boolean);
310    }
311
312    function getMaxSingleLineCandidate(brief, text) {
313        const normalized = normalizeText(text);
314
315        if (!normalized) {
316            return '';
317        }
318
319        let low = 1;
320        let high = normalized.length;
321        let best = '';
322
323        while (low <= high) {
324            const mid = Math.floor((low + high) / 2);
325            const candidate = normalized.slice(0, mid).trimEnd() + ELLIPSIS;
326
327            if (fitsInSingleLine(brief, candidate)) {
328                best = candidate;
329                low = mid + 1;
330            } else {
331                high = mid - 1;
332            }
333        }
334
335        return best;
336    }
337
338    function getMaxMultiLineCandidateByChars(brief, text, maxLines) {
339        const normalized = normalizeText(text);
340
341        if (!normalized) {
342            return '';
343        }
344
345        let low = 1;
346        let high = normalized.length;
347        let best = '';
348
349        while (low <= high) {
350            const mid = Math.floor((low + high) / 2);
351            const candidate = normalized.slice(0, mid).trimEnd() + ELLIPSIS;
352
353            if (fitsInLineLimitOffscreen(brief, candidate, maxLines)) {
354                best = candidate;
355                low = mid + 1;
356            } else {
357                high = mid - 1;
358            }
359        }
360
361        return best;
362    }
363
364    function truncateBriefToLineCount(brief, maxLines) {
365        const original = getOriginalBriefText(brief);
366
367        if (!original) {
368            return false;
369        }
370
371        // Always start from full text so recalculation on resize/zoom is stable.
372        brief.textContent = original;
373
374        if (fitsWithinLineLimit(brief, maxLines)) {
375            return false;
376        }
377
378        const words = splitWords(original);
379
380        if (words.length === 0) {
381            return;
382        }
383
384        if (maxLines === 1) {
385            const bestCandidate = getMaxSingleLineCandidate(brief, original);
386            const fallbackOneLine = bestCandidate || (words[0] + ELLIPSIS);
387            brief.textContent = fallbackOneLine;
388            return brief.textContent !== original;
389        }
390
391        const bestCandidate = getMaxMultiLineCandidateByChars(brief, original, maxLines);
392
393        if (bestCandidate) {
394            brief.textContent = bestCandidate;
395            return brief.textContent !== original;
396        }
397
398        // If even one word + ellipsis does not fit, keep one-word ellipsis as
399        // the smallest meaningful destructive truncation.
400        brief.textContent = words[0] + ELLIPSIS;
401        return brief.textContent !== original;
402    }
403
404    function clampCard(card) {
405        const brief = card.querySelector(BRIEF_SELECTOR);
406
407        if (!brief) {
408            return false;
409        }
410
411        const titleLines = Math.max(1, getTitleLines(card));
412        const briefLines = getDescriptionLinesForTitleLines(titleLines);
413
414        return truncateBriefToLineCount(brief, briefLines);
415    }
416
417    function applyAllCards(options = {}) {
418        const emitUpdate = options.emitUpdate !== false;
419        const onlyVisible = options.onlyVisible === true;
420        const cards = Array.from(document.querySelectorAll(CARD_SELECTOR));
421
422        isApplying = true;
423
424        if (observer) {
425            observer.disconnect();
426        }
427
428        let hasTextUpdates = false;
429
430        cards.forEach(function(card) {
431            const rect = card.getBoundingClientRect();
432            const isVisible = rect.width > 0 && rect.height > 0;
433            if (!isVisible) {
434                return;
435            }
436
437            if (onlyVisible) {
438                const carouselItem = card.closest('.carousel-item');
439                if (carouselItem && !carouselItem.classList.contains('active')) {
440                    return;
441                }
442            }
443
444            if (isVisible) {
445                hasTextUpdates = clampCard(card) || hasTextUpdates;
446            }
447        });
448
449        if (observer) {
450            observer.observe(document.body, {
451                childList: true,
452                subtree: true,
453            });
454        }
455
456        if (emitUpdate && hasTextUpdates) {
457            document.dispatchEvent(new CustomEvent('djs:report-card-clamp-updated'));
458        }
459
460        isApplying = false;
461    }
462
463    function debounce(fn, delay) {
464        let timer = null;
465
466        return function() {
467            clearTimeout(timer);
468            timer = setTimeout(fn, delay);
469        };
470    }
471
472    function runAfterLayoutSettles(options = {}) {
473        applyAllCards(options);
474
475        window.requestAnimationFrame(function() {
476            applyAllCards(options);
477        });
478
479        window.setTimeout(function() { applyAllCards(options); }, 180);
480    }
481
482    const debouncedApply = debounce(runAfterLayoutSettles, 60);
483
484    document.addEventListener('DOMContentLoaded', runAfterLayoutSettles);
485    window.addEventListener('load', runAfterLayoutSettles);
486    window.addEventListener('resize', debouncedApply);
487    window.addEventListener('orientationchange', debouncedApply);
488
489    document.addEventListener('djs:cards-heights-equalized', function(event) {
490        const reason = event && event.detail ? event.detail.reason : '';
491
492        // Prevent ping-pong loop: clamp ->
492 equalize -> clamp -> equalize...
493        if (reason === 'clamp-update') {
494            return;
495        }
496
497        debouncedApply();
498    });
499
500    if (document.fonts && document.fonts.ready) {
501        document.fonts.ready.then(runAfterLayoutSettles).catch(function() {
502            // Ignore font-ready errors.
503        });
504    }
505
506    observer = new MutationObserver(function(mutations) {
507        if (isApplying) {
508            return;
509        }
510
511        function affectsReportCards(mutation) {
512            const target = mutation.target;
513            const targetElement = target instanceof Element ? target : target.parentElement;
514
515            if (!targetElement) {
516                return false;
517            }
518
519            if (targetElement.closest(CARD_SELECTOR)) {
520                return true;
521            }
522
523            const addedNodes = Array.from(mutation.addedNodes || []);
524            return addedNodes.some(function(node) {
525                if (!(node instanceof Element)) {
526                    return false;
527                }
528
529                return Boolean(
530                    node.matches(CARD_SELECTOR) ||
531                    node.querySelector(CARD_SELECTOR)
532                );
533            });
534        }
535
536        const hasExternalMutation = mutations.some(function(mutation) {
537            const node = mutation.target;
538            const element = node instanceof Element ? node : node.parentElement;
539
540            if (!element) {
541                return false;
542            }
543
544            if (element.closest(BRIEF_SELECTOR)) {
545                return false;
546            }
547
548            return affectsReportCards(mutation);
549        });
550
551        if (hasExternalMutation) {
552            debouncedApply();
553        }
554    });
555
556    observer.observe(document.body, {
557        childList: true,
558        subtree: true,
559    });
560})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.