fix(site): measure the longest string width instead of estimating it (#2411)

Co-authored-by: henrygd <hank@henrygd.me>
This commit is contained in:
Mario Mohar
2026-09-24 11:32:21 -04:00
committed by GitHub
co-authored by henrygd
parent fc33e62736
commit e4b84b72ab
5 changed files with 73 additions and 11 deletions
@@ -138,7 +138,7 @@ export function getMonitorColumns(
</div>
</div>
),
[status, name]
[status, name, longestSystemName]
)
},
},
@@ -36,7 +36,7 @@ import { useToast } from "@/components/ui/use-toast"
import { isReadOnlyUser, queueUserSettings } from "@/lib/api"
import { pb } from "@/lib/api"
import { SystemStatus } from "@/lib/enums"
import { $allSystemsById, $direction, $userSettings, getUserChartTime } from "@/lib/stores"
import { $allSystemsById, $direction, $textMeasureVersion, $userSettings, getUserChartTime } from "@/lib/stores"
import { cn, formatShortDate, isVisuallyLonger, matchesFilterGroups, parseFilterGroups, parseSemVer } from "@/lib/utils"
import type { ChartData, MonitorCertInfo, NetworkMonitorRecord } from "@/types"
import { AddMonitorDialog, EditMonitorDialog } from "./monitor-dialog"
@@ -145,6 +145,8 @@ export default function NetworkMonitorsTableNew({
[sortSettingsKey, sortStorageKey]
)
// recompute when measured widths are invalidated (e.g. web font finished loading)
const textMeasureVersion = useStore($textMeasureVersion)
const longestTarget = useMemo(() => {
let longestTarget = ""
for (const p of monitors) {
@@ -153,7 +155,7 @@ export default function NetworkMonitorsTableNew({
}
}
return longestTarget
}, [monitors])
}, [monitors, textMeasureVersion])
const runMonitorBatch = useCallback(
async (ids: string[], enqueue: (batch: ReturnType<typeof pb.createBatch>, id: string) => void) => {
+5
View File
@@ -93,3 +93,8 @@ export const $direction = atom<"ltr" | "rtl">("ltr")
/** Longest system name string. Used to reserve width in virtualized tables. */
export const $longestSystemName = atom("")
/** Incremented when measured text widths are invalidated (e.g. web font finished loading).
* Anything that caches a comparison from isVisuallyLonger should recompute when this changes.
*/
export const $textMeasureVersion = atom(0)
+18 -7
View File
@@ -7,6 +7,7 @@ import {
$downSystems,
$longestSystemName,
$pausedSystems,
$textMeasureVersion,
$upSystems,
} from "@/lib/stores"
import { isVisuallyLonger, updateFavicon } from "@/lib/utils"
@@ -67,6 +68,11 @@ export function init() {
// run things that need to be done when systems change
onSystemsChanged(newSystems, newSystem, oldSystem)
})
// widths measured with the fallback font may rank names differently, so recompute once they're invalidated
$textMeasureVersion.listen(() => {
$longestSystemName.set(findLongestName($allSystemsById.get()))
})
}
/** Update the longest system name string and favicon based on system status */
@@ -78,13 +84,7 @@ function onSystemsChanged(systems: Record<string, SystemRecord>, newSystem?: Sys
// otherwise, if the changed system's new name is longer than the current longest, update it
const longestName = $longestSystemName.get()
if (oldSystem?.name === longestName && oldSystem.name !== newSystem?.name) {
let newLongest = ""
for (const id in systems) {
if (isVisuallyLonger(systems[id].name, newLongest)) {
newLongest = systems[id].name
}
}
$longestSystemName.set(newLongest)
$longestSystemName.set(findLongestName(systems))
} else if (newSystem && newSystem.name !== longestName && isVisuallyLonger(newSystem.name, longestName)) {
$longestSystemName.set(newSystem.name)
}
@@ -92,6 +92,17 @@ function onSystemsChanged(systems: Record<string, SystemRecord>, newSystem?: Sys
updateFavicon(downSystems.length)
}
/** Find the visually longest system name */
function findLongestName(systems: Record<string, SystemRecord>): string {
let longest = ""
for (const id in systems) {
if (isVisuallyLonger(systems[id].name, longest)) {
longest = systems[id].name
}
}
return longest
}
/** Fetch systems from collection */
async function fetchSystems(): Promise<SystemRecord[]> {
try {
+45 -1
View File
@@ -7,7 +7,7 @@ import { twMerge } from "tailwind-merge"
import { toast } from "@/components/ui/use-toast"
import type { ChartTimeData, FingerprintRecord, SemVer, SystemRecord } from "@/types"
import { HourFormat, Unit } from "./enums"
import { $copyContent, $userSettings } from "./stores"
import { $copyContent, $textMeasureVersion, $userSettings } from "./stores"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
@@ -451,6 +451,45 @@ export function runOnce<T extends (...args: any[]) => any>(fn: T): T {
const visualWidthCache = new Map<string, number>()
let measureContext: CanvasRenderingContext2D | null | undefined
let measureFont = ""
/** Canvas context for measuring text in the font the app renders with, or null where canvas is unavailable.
* Only relative widths matter here, so the font size is arbitrary.
*/
function getMeasureContext(): CanvasRenderingContext2D | null {
if (measureContext === undefined) {
measureContext = document.createElement("canvas").getContext("2d")
// the fallback font has different metrics, so re-measure whenever a font finishes loading.
// loadingdone also covers fonts that start loading after the first measurement,
// which fonts.ready does not if it has already resolved.
if (measureContext && "fonts" in document) {
document.fonts.addEventListener("loadingdone", invalidateVisualWidths)
}
}
if (measureContext) {
const { fontFamily, fontWeight } = getComputedStyle(document.body)
const font = `${fontWeight} 16px ${fontFamily}`
if (font !== measureFont) {
const isFirstFont = !measureFont
measureFont = font
measureContext.font = font
visualWidthCache.clear()
// defer so stores aren't updated in the middle of a comparison or a render
if (!isFirstFont) {
queueMicrotask(invalidateVisualWidths)
}
}
}
return measureContext
}
/** Drop cached widths and notify anything holding a result from isVisuallyLonger */
function invalidateVisualWidths() {
visualWidthCache.clear()
$textMeasureVersion.set($textMeasureVersion.get() + 1)
}
/** Get the visual width of a string, accounting for full-width and narrow punctuation characters.
* Don't use for monospaced fonts, use .length instead
*/
@@ -459,6 +498,11 @@ function getVisualStringWidth(str: string): number {
if (cached !== undefined) {
return cached
}
const measured = getMeasureContext()?.measureText(str).width
if (measured !== undefined) {
visualWidthCache.set(str, measured)
return measured
}
let width = 0
for (const char of str) {
if (char === ".") {