diff --git a/bun.lock b/bun.lock index cd5a7613..1407e75a 100644 --- a/bun.lock +++ b/bun.lock @@ -22,6 +22,9 @@ }, }, }, + "patchedDependencies": { + "ournotes-player@0.1.5": "patches/ournotes-player@0.1.5.patch", + }, "packages": { "@astrojs/check": ["@astrojs/check@0.9.10", "https://registry.npmmirror.com/@astrojs/check/-/check-0.9.10.tgz", { "dependencies": { "@astrojs/language-server": "^2.16.7", "chokidar": "^4.0.3", "kleur": "^4.1.5", "yargs": "^18.0.0" }, "peerDependencies": { "typescript": "^5.0.0 || ^6.0.0" }, "bin": { "astro-check": "./bin/astro-check.js" } }, "sha512-zgx/UQMozdjOa3bOxjgeCFdtpE3c9rRX6xHwa+2QXvy8z8Akifu2AtubHyv/zzC2znO8dl8fFWL4K+Ba9kS8HQ=="], diff --git a/docs/snap-ranking-browser-proof.json b/docs/snap-ranking-browser-proof.json new file mode 100644 index 00000000..98d63526 --- /dev/null +++ b/docs/snap-ranking-browser-proof.json @@ -0,0 +1,55 @@ +{ + "observedAt": "2026-10-01", + "scope": "CNB Chromium, existing Rust WASM and a SHA-bound saved TW snapshot; not latest JP runtime certification", + "source": { + "region": "tw", + "masterVersion": "0b21c9f4a3911370d5f981fb894c7544", + "modelCommit": "dbd9cf01a4854808dceb6aedcd4041af372faeee", + "fixtureManifestSha256": "27cc19ec2cc30594984e1db02f717221d8977e7b40ec5a505b8695ed8e2b388d", + "deckDataSha256": "589e5631cc4595bb4da12bc15841b6c87cedc8662cfb48342c1d6a0f703ef8a4", + "labelsSha256": "a27d19c14b286f1263c2aef5f369b23cae799c9e4f600ee0a60512bbf4257984" + }, + "ui": { + "defaultSlots": 5, + "defaultSelected": 0, + "defaultReplayRequests": 0, + "profile": { "memberSkillPercent": [100,100,100,100,100], "power": 300000, "greatFraction": 0.4, "justFraction": 1, "mode": "fixedSoloGekisou", "ranks": [1,1,1], "seed": 1, "fps": 60, "skillOrder": [0,1,2,3,4] }, + "skill": { "kind": "support", "skillId": 31, "level": 1 }, + "oneSlot": { "memberIds": [1,null,null,null,null], "gekisouLevels": [null,null,null,null,null], "chartsCompleted": 85, "youth": { "score":4459467,"delta":5124 }, "ave": { "score":2850116,"delta":1320 } }, + "fiveSlots": { "memberIds": [1,2,3,4,5], "gekisouLevels": [null,null,null,null,null], "chartsCompleted": 85, "youth": { "score":4469543,"delta":15200 }, "ave": { "score":2866889,"delta":18093 } }, + "urlReloadSameResult": true, + "needsContextCanReopenPicker": true, + "detail": { "sameMeasuredPoint":true,"easySiblingCalculates":true,"linearWeights":false,"thresholdColumns":2,"thresholdMode":"raw-solo" }, + "resetSelected": 0, + "resetRemovesSelectionQuery": true, + "mobileViewport": [390,844], + "mobileDocumentOverflow": false, + "pageErrors": [] + }, + "devIntegration": { + "baseCommit": "9be87d787d9a8219e67b4c12f90948b11c702acc", + "environment": "Windows headed Codex browser, Astro development server", + "sameSourceAndUiProfile": true, + "tests": 187, + "assertions": 679, + "typescriptVersion": "5.9.3", + "selectedSlots": 5, + "coverButtons": 0, + "chartsCompleted": 85, + "youthScore": 4469543, + "aveScore": 2866889, + "detailThresholdColumns": 2, + "detailLegacyWeights": 0, + "detailIndependentEditor": 0, + "easySibling": { "scoreId": 10002100, "score": 3391762, "baselineScore": 3355940, "delta": 35822 } + }, + "productionWorker": { + "build": "Vite production ES-module Worker", + "profileDifferenceFromUi": { "seed":0,"pairedGekisouLevels":[1,1,1,1,1] }, + "matchesIndependentSameInputWasmProbe": true, + "rows": [ + { "scoreId":10002103,"score":4469543,"baselineScore":4454343,"delta":15200,"convertedJudgements":15 }, + { "scoreId":10002603,"score":2900352,"baselineScore":2882211,"delta":18141,"convertedJudgements":15 } + ] + } +} diff --git a/docs/snap-ranking.md b/docs/snap-ranking.md new file mode 100644 index 00000000..015ad182 --- /dev/null +++ b/docs/snap-ranking.md @@ -0,0 +1,33 @@ +# Optional Snap skills in chart rankings + +The ranking page accepts five optional, physically paired Snap skill slots. All five default to None. Clearing the profile restores the existing ranking and requires no WASM download. A selection records its ordinary/Gekisou kind, actual skill ID and level, rather than assuming a card's maximum growth. + +The picker reuses `Modal`, `FilterButton`, `SupportCardArtwork` and `MemberCardArtwork`. Names, descriptions and IDs are searchable with NFKC normalization and AND tokens; source card names, characters and bands are included. Levels stay explicit. Skills that require a paired member expose its reference card and Gekisou level. A missing condition returns `needs-context`, with no guessed score. + +## Calculation and architecture + +The source client transports files. `snap-bridge` verifies replay manifest/resource SHA-256, sizes, region, master version and model commit before calling the existing Rust WASM `ReplaySession`. `snap-catalogue`, `snap-query`, `snap-profile` and source view models handle input and display identities. The Worker owns sessions, bounded point-result caching, job revisions and cancellation. React renders controls and measured results. There is no JavaScript scoring formula or WebGPU path. + +Selected skills use a declared standard profile: five ordinary 5-second score-up skills at the percentages already entered, chosen measurement power, Great/Just input preset, seed 1, 60 fps, physical skill order 1–5, and Free Live or solo Gekisou with three fixed ranks. Reference members provide real attributes for conditions; their growth power and original live skill are not substituted for this standard profile. This is a reproducible measurement, not a random-order expectation, player's owned deck, or multiplayer simulation. + +Each chart is replayed both with the selections and with all five Snap slots removed, retaining the other inputs. Rank rows show simulated score, signed difference and score per minute. Pending or invalid rows remain empty. The chart detail reuses the same point; switching difficulty adds its chart to the replay job. It shows raw solo thresholds and chart event markers, without guessing Snap duration changes or deriving required power, success probability or dominance from legacy linear weights. Those objectives need the separate search in [the model/search design](deck-integration.md). The legacy range metrics, Gekisou aptitude and independent replay editor use other profiles and are hidden while Snap is active. + +Changing a profile or source immediately hides previous results. Worker revisions reject late jobs; it yields after each paired chart result and disposes sessions on cancellation. Cache keys include the complete source identity, profile and chart ID, with a 1,500-point LRU limit. Changes to displayed duration or overhead reuse scores and only reorder the table. + +## Source updates + +`nnnotes` [#7](https://github.com/StarMoe-org/nnnotes/pull/7) adds region-separated source refresh, consumer identity and raw score-rank validation. [#8](https://github.com/StarMoe-org/nnnotes/pull/8) exports a SHA-bound `nnnotes.replay-labels/1` sidecar at `replay.snapLabels`, containing the same snapshot's skill descriptions and card artwork metadata. Payload resources precede their manifest during publishing. + +The UI never borrows the site's merged build-time master tables. Without the optional label sidecar it uses names from the same music-data catalogue, omits unexpanded descriptions and artwork with unknown asset IDs, and keeps ID search available. Card artwork uses the verified music snapshot's server scope. `MasterSkillTarget` names resolve through their actual character/band predicates; condition targets are separate from effect targets. + +Judged-input replays cannot reproduce raw touch timing effects. Those choices are visible and disabled with an explanation. This feature's current source/model snapshot is not certification of the latest JP 1.0.4 runtime or online IFix rules. The native whole-live and search gates remain separate prerequisites for formal deck recommendation. + +## Verification + +Pure tests cover default None, URL restoration, slot pairing, search/filter/level behavior, source mismatch, unresolved descriptions, source/profile changes, stale Worker responses, cancellation, bounded cache and the unchanged legacy ranking. The browser check runs on CNB with the actual shipped WASM and SHA-bound same-snapshot fixtures, not mock scores. Full source resources stay outside Git. Five core locales, architecture, routes, UI text and search/SEO checks are required. + +[Browser proof](snap-ranking-browser-proof.json) records 85-chart real UI runs, five slots, URL restoration, mobile overflow, and the production-built Worker matching two independently captured same-input WASM cases. The proof distinguishes the UI seed/member Gekisou settings from those two reference cases. It does not claim native-game certification from a WASM-to-WASM comparison. + +The `ournotes-player@0.1.5` Bun patch exposes the original player input-plan helper without changing its algorithm. Its source is the player file at commit `0e2c2352b01fa0fad26cd8365d09be00737d63eb`; the package release otherwise omits that example directory and export. Use Bun 1.4.2 to reproduce the patched installation and tests. + +The feature and data-update PRs integrate into `dev`. Production data publishing and native whole-live certification remain separate steps. diff --git a/package.json b/package.json index ffd08aaa..6a367d92 100644 --- a/package.json +++ b/package.json @@ -37,5 +37,8 @@ "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "typescript": "^5.9.3" + }, + "patchedDependencies": { + "ournotes-player@0.1.5": "patches/ournotes-player@0.1.5.patch" } } diff --git a/patches/ournotes-player@0.1.5.patch b/patches/ournotes-player@0.1.5.patch new file mode 100644 index 00000000..4dc4e4ba --- /dev/null +++ b/patches/ournotes-player@0.1.5.patch @@ -0,0 +1,199 @@ +diff --git a/node_modules/ournotes-player/examples/songs/replay-preset.js b/examples/songs/replay-preset.js +new file mode 100644 +index 0000000000000000000000000000000000000000..d4b99b0d30086c432219398cecbb56a0dd278b8f +--- /dev/null ++++ b/examples/songs/replay-preset.js +@@ -0,0 +1,151 @@ ++// Declared input plans only. Rust owns conversion, state updates and scoring. ++ ++/** Read Just capability from a caller-verified nnnotes.deck-data/1 document. */ ++export function parseJustJudgementTypes(deckDataJSON) { ++ const data = typeof deckDataJSON === "string" ? JSON.parse(deckDataJSON) : deckDataJSON; ++ if (data?.format !== "nnnotes.deck-data/1") throw new Error("Unsupported replay data format"); ++ const table = data.master?.MasterLiveJudgementTiming; ++ if (!Array.isArray(table?.columns) || !Array.isArray(table.rows)) throw new Error("Missing judgement timing table"); ++ const type = table.columns.indexOf("_noteJudgementType"); ++ const grade = table.columns.indexOf("_noteSimulateJudgement"); ++ if (type < 0 || grade < 0) throw new Error("Missing judgement timing columns"); ++ const found = new Set(); ++ for (const row of table.rows) { ++ if (!Array.isArray(row) || !Number.isInteger(row[type]) || !Number.isInteger(row[grade])) { ++ throw new Error("Invalid judgement timing row"); ++ } ++ if (row[grade] === 6) found.add(row[type]); ++ } ++ return [...found]; ++} ++ ++// Exactly count positions, spread evenly over the existing order. The prefix count differs ++// from prefixLength * count / length by less than one. No shuffle or scoring formula. ++function chooseEvenly(items, count, set) { ++ let remainder = 0; ++ for (const item of items) { ++ remainder += count; ++ if (remainder >= items.length) { ++ remainder -= items.length; ++ set(item); ++ } ++ } ++} ++ ++/** ++ * Replace only input grades, after validating the entire plan. Pass and frame/note order stay intact. ++ * Great covers all judged inputs; Just covers the remaining Just-capable inputs in enabled frames. ++ * Fractions choose rounded counts in one deterministic plan, not an expectation over plans. ++ */ ++function preparePlan(request, description, justTypes) { ++ if (request?.format !== "ournotes.replay/1" || !Array.isArray(request.frames) || !Array.isArray(description?.notes)) { ++ throw new Error("Invalid replay plan"); ++ } ++ if (request.rawRuntime != null) throw new Error("Accuracy presets require a judgement template without raw results"); ++ if (!Array.isArray(justTypes) && !(justTypes instanceof Set)) throw new Error("Missing Just judgement types"); ++ const types = new Set(justTypes); ++ if ([...types].some(n => !Number.isInteger(n))) throw new Error("Invalid Just judgement type"); ++ const mode = request.mode?.kind; ++ if (!["normal", "soloGekisou", "fixedSoloGekisou", "externalGekisou"].includes(mode)) { ++ throw new Error("Unsupported replay mode"); ++ } ++ const byId = new Map(description.notes.map(note => [note.noteId, note])); ++ if (byId.size !== description.notes.length) throw new Error("Duplicate chart note id"); ++ const inputs = [], seen = new Set(); ++ for (let f = 0; f < request.frames.length; f++) { ++ const frame = request.frames[f]; ++ if (!Number.isInteger(frame.timeMs) || !Array.isArray(frame.judgements)) throw new Error("Invalid replay frame"); ++ for (const judgement of frame.judgements) { ++ if (judgement.rawResult != null) throw new Error("Accuracy presets require a judgement template without raw results"); ++ const note = byId.get(judgement.noteId); ++ if (!note || seen.has(judgement.noteId)) throw new Error("Unknown or duplicate replay note"); ++ seen.add(judgement.noteId); ++ if (note.defaultJudgement === 7 || judgement.judgement === 7) continue; ++ inputs.push({ judgement, note, frame: f, grade: 5 }); ++ } ++ } ++ const windows = []; ++ if (mode !== "normal") { ++ if (!Array.isArray(description.fevers) || !Array.isArray(description.missions)) throw new Error("Missing Gekisou ranges"); ++ for (let i = 0; i < description.fevers.length; i++) { ++ const fever = description.fevers[i]; ++ if (!Array.isArray(fever) || fever.length !== 2 || !fever.every(Number.isInteger)) throw new Error("Invalid fever range"); ++ if (description.missions[i] !== 3) continue; ++ const first = request.frames.findIndex(frame => frame.timeMs >= fever[0]); ++ if (first < 0) continue; ++ let stop = first + 1; ++ while (stop < request.frames.length && request.frames[stop].timeMs < fever[1]) stop++; ++ windows.push([first, stop]); ++ } ++ } ++ return { inputs, canJust: input => types.has(input.note.judgementType) ++ && windows.some(([first, stop]) => first <= input.frame && input.frame < stop) }; ++} ++ ++export function applyAccuracyPreset(request, description, justTypes, greatFraction, justFraction) { ++ if (![greatFraction, justFraction].every(n => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 1)) { ++ throw new Error("Accuracy fractions must be between 0 and 1"); ++ } ++ const { inputs, canJust } = preparePlan(request, description, justTypes); ++ const great = Math.round(inputs.length * greatFraction); ++ chooseEvenly(inputs, great, input => { input.grade = 4; }); ++ const eligible = inputs.filter(input => input.grade !== 4 && canJust(input)); ++ const just = Math.round(eligible.length * justFraction); ++ chooseEvenly(eligible, just, input => { input.grade = 6; }); ++ // No validation or operation that can throw remains after the first input write. ++ for (const input of inputs) input.judgement.judgement = input.grade; ++ return { great, just, perfect: inputs.length - great - just, total: inputs.length, justEligible: eligible.length }; ++} ++ ++/** ++ * Generate one seeded, explicit input stream. The first segment is the whole-song default; ++ * later [startMs,endMs) segments override earlier ones by chart noteMs. ++ * Input PRNG: LCG32, x <- 1664525*x + 1013904223 modulo 2^32; uniform x/2^32. ++ * Exactly two draws per judged input, including inputs outside Just windows. Pass consumes none. ++ * This plan seed is independent of request.seed, the game's skill/luck seed. ++ */ ++export function applySegmentPreset(request, description, justTypes, segments, planSeed) { ++ if (!Number.isInteger(planSeed) || planSeed < 0 || planSeed > 0xffffffff) throw new Error("Plan seed must be a uint32 integer"); ++ if (!Array.isArray(segments) || segments.length === 0) throw new Error("A whole-song default segment is required"); ++ const rules = segments.map((segment, i) => { ++ if (!segment || (i === 0 ? segment.startMs !== null || segment.endMs !== null ++ : !Number.isInteger(segment.startMs) || !Number.isInteger(segment.endMs) || segment.startMs >= segment.endMs)) { ++ throw new Error("Segment zero must cover the song; later segments need integer startMs < endMs"); ++ } ++ const p = [segment.great, segment.good, segment.bad, segment.miss, segment.just]; ++ if (!p.every(n => typeof n === "number" && Number.isFinite(n) && n >= 0 && n <= 1)) { ++ throw new Error("Segment probabilities must be between 0 and 1"); ++ } ++ if (p.slice(0, 4).reduce((a, b) => a + b, 0) > 1 + 4 * Number.EPSILON) { ++ throw new Error("Great + Good + Bad + Miss probabilities must not exceed 1"); ++ } ++ return { ...segment }; ++ }); ++ const { inputs, canJust } = preparePlan(request, description, justTypes); ++ if (inputs.some(input => !Number.isInteger(input.note.noteMs))) throw new Error("Segment plans require chart noteMs"); ++ let state = planSeed >>> 0; ++ const random = () => { ++ state = (Math.imul(state, 1664525) + 1013904223) >>> 0; ++ return state / 0x100000000; ++ }; ++ const counts = { great: 0, good: 0, bad: 0, miss: 0, just: 0, perfect: 0, total: inputs.length }; ++ for (const input of inputs) { ++ let rule = rules[0]; ++ for (let i = 1; i < rules.length; i++) { ++ if (rules[i].startMs <= input.note.noteMs && input.note.noteMs < rules[i].endMs) rule = rules[i]; ++ } ++ const gradeDraw = random(), justDraw = random(); ++ let limit = 0, selected = "perfect"; ++ for (const [name, grade] of [["great", 4], ["good", 3], ["bad", 2], ["miss", 1]]) { ++ limit += rule[name]; ++ if (gradeDraw < limit) { selected = name; input.grade = grade; break; } ++ } ++ if (selected === "perfect" && canJust(input) && justDraw < rule.just) { ++ selected = "just"; input.grade = 6; ++ } ++ counts[selected]++; ++ } ++ // Everything is validated and all draws resolved before mutating the caller's stream. ++ for (const input of inputs) input.judgement.judgement = input.grade; ++ return counts; ++} +diff --git a/package.json b/package.json +index d7061ce0d1d2dfafaa54c5984e9ba85b22eefdb2..e302f9fd76b7f5f2af49f3f884d43e333c04234c 100644 +--- a/package.json ++++ b/package.json +@@ -53,7 +53,11 @@ + }, + "./dist/*": "./dist/*", + "./schema/*": "./schema/*", +- "./package.json": "./package.json" ++ "./package.json": "./package.json", ++ "./replay/preset": { ++ "types": "./types/replay-preset.d.ts", ++ "default": "./examples/songs/replay-preset.js" ++ } + }, + "sideEffects": [ + "./src/element.js", +@@ -81,7 +85,8 @@ + "examples/story/", + "examples/story-list/", + "LICENSE", +- "LICENSE-EXCEPTION" ++ "LICENSE-EXCEPTION", ++ "examples/songs/replay-preset.js" + ], + "engines": { + "node": ">=20" +diff --git a/node_modules/ournotes-player/types/replay-preset.d.ts b/types/replay-preset.d.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..2f695a7005384d3f116cea115b73c2a936a8ddcb +--- /dev/null ++++ b/types/replay-preset.d.ts +@@ -0,0 +1,9 @@ ++/** Declared input plans; the Rust replay retains conversion, state updates and scoring. */ ++export function parseJustJudgementTypes(deckData: string | Record): number[]; ++export function applyAccuracyPreset( ++ request: Record, ++ description: Record, ++ justTypes: readonly number[] | Set, ++ greatFraction: number, ++ justFraction: number, ++): Record; diff --git a/src/components/chart-data/ChartDataTool.tsx b/src/components/chart-data/ChartDataTool.tsx index a6546e08..e1e7a7fa 100644 --- a/src/components/chart-data/ChartDataTool.tsx +++ b/src/components/chart-data/ChartDataTool.tsx @@ -1,5 +1,6 @@ import { memo, useCallback, useEffect, useMemo, useState } from "react"; import type { AppLocale } from "@/config/locales"; +import { assetConfig } from "@/config/assets"; import { t } from "@/i18n"; import type { ChartDataGuide } from "@/i18n/guides/chart-data"; import { localizePath } from "@/i18n/routing"; @@ -12,6 +13,8 @@ import { parseChartDataQuery, playScenario, serializeChartDataQuery, VIEWS, type import { scenarioSupport } from "@/lib/chart-data/scenario"; import { localizeDataText } from "@/lib/chart-data/text"; import type { MusicData } from "@/lib/chart-data/types"; +import { chartSnapProfile, snapDisplayedMeasurement } from "@/lib/chart-data/snap-profile"; +import { emptySnapRanking, isCurrentSnapCatalogue, type SnapRankingCatalogue, type SnapRankingSource, type SnapRankingState } from "@/lib/chart-data/snap-client"; import { getChartPreviewHref } from "@/lib/music/chart-preview"; import { getMusicJacketUrl } from "@/lib/music/data"; import { isMusicDifficulty } from "@/lib/music/difficulty"; @@ -20,6 +23,8 @@ import ChartDetail from "./ChartDetail"; import ChartsView from "./ChartsView"; import GuideView from "./GuideView"; import RankView from "./RankView"; +import SnapRankingController from "./SnapRankingController"; +import SnapRankingPanel from "./SnapRankingPanel"; import { Icon, diffShort, type ChartDataContext } from "./shared"; interface Props { @@ -44,6 +49,11 @@ export default function ChartDataTool({ locale, guide }: Props) { const [view, setView] = useState("rank"); const [restored, setRestored] = useState(false); const [state, setState] = useState(null); + const [snapRequested, setSnapRequested] = useState(false); + const [snapCatalogue, setSnapCatalogue] = useState(null); + const [snapCatalogueError, setSnapCatalogueError] = useState(false); + const [snapAttempt, setSnapAttempt] = useState(0); + const [snapMeasurement, setSnapMeasurement] = useState(emptySnapRanking); useEffect(() => { const v = new URLSearchParams(window.location.search).get("v"); @@ -61,6 +71,8 @@ export default function ChartDataTool({ locale, guide }: Props) { }, [attempt]); const data = load.status === "ready" ? load.data : null; + const currentSnapCatalogue = useMemo(() => isCurrentSnapCatalogue(snapCatalogue, data) ? snapCatalogue : null, [snapCatalogue, data]); + useEffect(() => { setSnapCatalogue(null); setSnapCatalogueError(false); }, [data]); const support = useMemo(() => scenarioSupport(data), [data]); const hasStats = support.battle || support.free; const queryContext = useMemo(() => ({ hasStats, support }), [hasStats, support]); @@ -102,6 +114,18 @@ export default function ChartDataTool({ locale, guide }: Props) { const mode = state?.mode, ranks = state?.ranks, great = state?.great, just = state?.just; const scenario = useMemo(() => (mode && ranks && great !== undefined && just !== undefined ? playScenario({ mode, ranks, great, just }) : null), [mode, ranks, great, just]); const rows = useMemo(() => (data && scenario ? chartRows(data, scenario) : []), [data, scenario]); + const snapActive = current?.snapSkills.some(Boolean) ?? false; + const snapEvaluation = useMemo(() => current ? chartSnapProfile(current, currentSnapCatalogue?.data) : null, [current, currentSnapCatalogue]); + const snapSource = useMemo(() => { + const region = data?.provenance?.region, masterVersion = data?.provenance?.master?.version, modelCommit = data?.provenance?.deck?.commit; + return data?.replay && region && masterVersion && modelCommit && (snapRequested || snapActive) + ? { site: assetConfig.musicDataSite, reference: data.replay, expected: { region, masterVersion, modelCommit } } : null; + }, [data, snapRequested, snapActive]); + const snapScoreIds = useMemo(() => { + const ids = view === "rank" ? rows.filter((r) => current?.diffs.includes(r.difficulty as typeof DIFFICULTIES[number]) && (!current.band || r.bandIds.map(String).includes(current.band))).map((r) => r.scoreId) : []; + if (current?.chart && rows.some((row) => row.scoreId === current.chart) && !ids.includes(current.chart)) ids.push(current.chart); + return ids; + }, [rows, current?.diffs, current?.band, current?.chart, view]); const ctx = useMemo(() => { if (!data || !current) return null; const bands = new Map((data.bands ?? []).map((b) => [String(b.id), b])); @@ -133,8 +157,9 @@ export default function ChartDataTool({ locale, guide }: Props) { : { rate: null, perMinute: null }), openChart: (scoreId) => update({ chart: scoreId }), pool: rows.filter((r) => current.diffs.includes(r.difficulty as typeof DIFFICULTIES[number]) && (!current.band || r.bandIds.map(String).includes(current.band))), + snap: snapEvaluation ? { active: snapActive, profile: snapEvaluation.profile, measurement: snapDisplayedMeasurement(snapEvaluation, snapSource, snapMeasurement), source: snapSource } : undefined, }; - }, [data, current, rows, locale, tr, hasStats, support, update]); + }, [data, current, rows, locale, tr, hasStats, support, update, snapEvaluation, snapActive, snapMeasurement, snapSource]); const detail = ctx && current?.chart ? ctx.byScore.get(current.chart) ?? null : null; @@ -162,12 +187,20 @@ export default function ChartDataTool({ locale, guide }: Props) { {load.message} - ) : ctx && view === "rank" ? : ctx ? : null} + ) : ctx && view === "rank" ? <> + { setSnapRequested(true); if (snapCatalogueError) { setSnapCatalogueError(false); setSnapAttempt((value) => value + 1); } }} /> + + : ctx ? : null} )} {data ?