@wodzik/cubecore 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/analyze/roux.d.ts CHANGED
@@ -18,6 +18,8 @@ import { type RouxSide } from "../roux/index.js";
18
18
  export interface RouxAnalyzeOptions {
19
19
  /** Physical faces the first block may go on (its left side); default all six. */
20
20
  sides?: readonly Face[];
21
+ /** Physical faces the blocks' bottom may be on (colour neutrality, like CFOP's `crosses`: e.g. ["U", "D"] = white / yellow); default all six. */
22
+ bottoms?: readonly Face[];
21
23
  /** Case ids the solver knows ("Sune Left Bar"…) — the CMLL step gets `known`. */
22
24
  known?: Iterable<string>;
23
25
  }
@@ -59,8 +61,8 @@ export interface RouxAnalysisResult {
59
61
  bySide: RouxAnalysis[];
60
62
  best: RouxAnalysis;
61
63
  }
62
- /** One side (and its best bottom): the whole way through, as held. */
63
- export declare function analyzeRouxSide(scrambled: State, side: Face, known?: Set<string> | null): RouxAnalysis;
64
+ /** One side (and its best bottom, out of `bottoms` if given): the whole way through, as held. */
65
+ export declare function analyzeRouxSide(scrambled: State, side: Face, known?: Set<string> | null, bottoms?: readonly Face[]): RouxAnalysis;
64
66
  /** Analyse a scramble for Roux, for each side the first block can go on. */
65
67
  export declare function analyzeRoux(scramble: string | readonly Move[] | State, options?: RouxAnalyzeOptions): RouxAnalysisResult;
66
68
  /** Does following it (as held) solve the cube? */
package/analyze/roux.js CHANGED
@@ -19,12 +19,15 @@ import { STAGES, lseSolver, stageSolver } from "../solve/index.js";
19
19
  import { rotationFor } from "./rotations.js";
20
20
  const FB_FIXED = { ...ROUX_TRAINERS.fb(), name: "roux-fb-fixed", neutral: undefined };
21
21
  const SIDES = ["front", "back"];
22
- /** One side (and its best bottom): the whole way through, as held. */
23
- export function analyzeRouxSide(scrambled, side, known = null) {
22
+ /** One side (and its best bottom, out of `bottoms` if given): the whole way through, as held. */
23
+ export function analyzeRouxSide(scrambled, side, known = null, bottoms) {
24
24
  // The best bottom colour for the first block on this side.
25
25
  const fb = stageSolver(FB_FIXED);
26
26
  let best = null;
27
- for (const frame of FRAMES.filter((f) => f.face.L === side)) {
27
+ const frames = FRAMES.filter((f) => f.face.L === side && (!bottoms || bottoms.includes(f.face.D)));
28
+ if (!frames.length)
29
+ throw new Error(`No allowed bottom next to side ${side}`);
30
+ for (const frame of frames) {
28
31
  const sol = fb.solve(reframe(scrambled, frame), { maxDepth: 12 })[0];
29
32
  if (sol && (!best || sol.length < best.moves.length))
30
33
  best = { frame, moves: sol };
@@ -82,8 +85,12 @@ export function analyzeRouxSide(scrambled, side, known = null) {
82
85
  export function analyzeRoux(scramble, options = {}) {
83
86
  const state = scramble instanceof Uint8Array ? scramble : applyMoves(solvedState(), typeof scramble === "string" ? parseAlg(scramble) : scramble);
84
87
  const known = options.known ? new Set(options.known) : null;
85
- const sides = options.sides ?? ["L", "R", "F", "B", "U", "D"];
86
- const bySide = sides.map((side) => analyzeRouxSide(state, side, known)).sort((a, b) => a.length - b.length);
88
+ const bottoms = options.bottoms;
89
+ // A side is possible only with an allowed bottom next to it (not on the side itself or opposite it).
90
+ const sides = (options.sides ?? ["L", "R", "F", "B", "U", "D"]).filter((side) => FRAMES.some((f) => f.face.L === side && (!bottoms || bottoms.includes(f.face.D))));
91
+ if (!sides.length)
92
+ throw new Error("No side has an allowed bottom");
93
+ const bySide = sides.map((side) => analyzeRouxSide(state, side, known, bottoms)).sort((a, b) => a.length - b.length);
87
94
  return { bySide, best: bySide[0] };
88
95
  }
89
96
  /** Does following it (as held) solve the cube? */
@@ -32,5 +32,10 @@ export declare const fraction: (time: number, duration: number) => number;
32
32
  * inverse, so playing it ends solved (or back at the setup).
33
33
  */
34
34
  export declare function startMoves(setup: string | readonly Move[], alg: readonly Move[], anchor?: "start" | "end"): Move[];
35
- /** Default tooltip text of a segment: "F2L 2 · FL · 3.21 s (recognition 0.80 · execution 2.41) · 9 moves". */
35
+ /** A segment's popup in two lines: the heading ("OLL · 2.64 s") and the details ("recognition 1.55 · execution 1.09 · 7 moves"). */
36
+ export declare function segmentLines(s: Segment): {
37
+ title: string;
38
+ details: string;
39
+ };
40
+ /** Default tooltip text of a segment (one line): "F2L 2 · FL · 3.21 s (recognition 0.80 · execution 2.41) · 9 moves". */
36
41
  export declare function segmentText(s: Segment): string;
package/element/model.js CHANGED
@@ -51,7 +51,17 @@ export function startMoves(setup, alg, anchor = "start") {
51
51
  const s = typeof setup === "string" ? parseAlg(setup) : [...setup];
52
52
  return anchor === "end" ? [...s, ...invert(alg)] : s;
53
53
  }
54
- /** Default tooltip text of a segment: "F2L 2 · FL · 3.21 s (recognition 0.80 · execution 2.41) · 9 moves". */
54
+ /** A segment's popup in two lines: the heading ("OLL · 2.64 s") and the details ("recognition 1.55 · execution 1.09 · 7 moves"). */
55
+ export function segmentLines(s) {
56
+ const title = [s.label, s.detail, `${formatTime(s.end - s.start)} s`].filter(Boolean).join(" · ");
57
+ const details = [];
58
+ if (s.split !== undefined)
59
+ details.push(`recognition ${formatTime(s.split - s.start)}`, `execution ${formatTime(s.end - s.split)}`);
60
+ if (s.moves !== undefined)
61
+ details.push(`${s.moves} move${s.moves === 1 ? "" : "s"}`);
62
+ return { title, details: details.join(" · ") };
63
+ }
64
+ /** Default tooltip text of a segment (one line): "F2L 2 · FL · 3.21 s (recognition 0.80 · execution 2.41) · 9 moves". */
55
65
  export function segmentText(s) {
56
66
  const parts = [s.label];
57
67
  if (s.detail)
@@ -74,6 +74,8 @@ export declare class CubePlayer extends ElementBase {
74
74
  private liveSource;
75
75
  private turnArrows;
76
76
  private clock;
77
+ /** How long a move animates (recording ms) — the clock's, and a step's. */
78
+ private maxAnimMs;
77
79
  private unsubscribe;
78
80
  private rec;
79
81
  private start;
@@ -144,8 +146,17 @@ export declare class CubePlayer extends ElementBase {
144
146
  get applied(): number;
145
147
  /** The moves being played (the algorithm, or the recording's moves). */
146
148
  get moves(): Move[];
149
+ /** The next move, animated (as it turns when playing, at the current speed). */
147
150
  stepForward(): void;
151
+ /** The last move undone, animated backwards. */
148
152
  stepBack(): void;
153
+ private stepFrame;
154
+ private stepEnd;
155
+ /** Run the clock from `from` to `to` (recording ms) in real time, then settle on `end`. */
156
+ private animateStep;
157
+ /** A step still animating: jump to where it ends (another step, or anything else, comes next). */
158
+ private finishStep;
159
+ private stopStep;
149
160
  toStart(): void;
150
161
  toEnd(): void;
151
162
  /** Follow a smart cube: its moves animate, resyncs jump, its gyro turns the cube. Returns detach. */
package/element/player.js CHANGED
@@ -42,8 +42,8 @@ import { applyMoves, parseAlg, solvedState, spinsAfter } from "../core/index.js"
42
42
  import { renderSvg } from "../image/index.js";
43
43
  import { CubeRenderer, showPosition } from "../render/index.js";
44
44
  import { SKINS } from "../skin/index.js";
45
- import { ReplayClock, compressPauses, segmentAt, segmentPlayed, stageSegments } from "../timeline/index.js";
46
- import { formatTime, fraction, segmentText, startMoves, stepTime, tempoRecording } from "./model.js";
45
+ import { ReplayClock, compressPauses, moveWindows, segmentAt, segmentPlayed, stageSegments } from "../timeline/index.js";
46
+ import { formatTime, fraction, segmentLines, segmentText, startMoves, stepTime, tempoRecording } from "./model.js";
47
47
  import { ICONS, STYLES } from "./styles.js";
48
48
  import { ElementBase } from "./base.js";
49
49
  const TEMPLATE = `
@@ -83,6 +83,8 @@ export class CubePlayer extends ElementBase {
83
83
  liveSource = null;
84
84
  turnArrows = null;
85
85
  clock = null;
86
+ /** How long a move animates (recording ms) — the clock's, and a step's. */
87
+ maxAnimMs = 150;
86
88
  unsubscribe = null;
87
89
  rec = { scramble: [], moves: [], totalMs: 0 };
88
90
  start = solvedState();
@@ -122,6 +124,7 @@ export class CubePlayer extends ElementBase {
122
124
  }
123
125
  disconnectedCallback() {
124
126
  this.connected = false;
127
+ this.stopStep();
125
128
  this.darkQuery?.removeEventListener("change", this.onScheme);
126
129
  this.clock?.pause();
127
130
  this.unsubscribe?.();
@@ -282,6 +285,7 @@ export class CubePlayer extends ElementBase {
282
285
  play() {
283
286
  if (!this.clock || this.playing)
284
287
  return;
288
+ this.finishStep();
285
289
  this.clock.play();
286
290
  this.dispatchEvent(new Event("play"));
287
291
  this.updateUi(this.currentTime);
@@ -300,6 +304,7 @@ export class CubePlayer extends ElementBase {
300
304
  this.play();
301
305
  }
302
306
  seek(ms) {
307
+ this.stopStep();
303
308
  this.clock?.seek(ms);
304
309
  }
305
310
  /** Jump to the moment `k` moves are done (0 = the start). */
@@ -315,13 +320,67 @@ export class CubePlayer extends ElementBase {
315
320
  get moves() {
316
321
  return this.rec.moves.map((m) => m.move);
317
322
  }
323
+ /** The next move, animated (as it turns when playing, at the current speed). */
318
324
  stepForward() {
319
325
  this.pause();
320
- this.seek(stepTime(this.rec, this.currentTime, 1));
321
- }
326
+ this.finishStep();
327
+ const time = this.currentTime;
328
+ const target = stepTime(this.rec, time, 1);
329
+ const k = this.rec.moves.findIndex((m) => m.t === target);
330
+ if (k < 0)
331
+ return this.seek(target);
332
+ const [from, to] = moveWindows(this.rec, { maxAnimMs: this.maxAnimMs })[k];
333
+ this.animateStep(Math.max(time, from), to, to);
334
+ }
335
+ /** The last move undone, animated backwards. */
322
336
  stepBack() {
323
337
  this.pause();
324
- this.seek(stepTime(this.rec, this.currentTime, -1));
338
+ this.finishStep();
339
+ const time = this.currentTime;
340
+ const target = stepTime(this.rec, time, -1);
341
+ // The move being undone: the last one finished by now.
342
+ let k = -1;
343
+ this.rec.moves.forEach((m, i) => {
344
+ if (m.t <= time + 0.5)
345
+ k = i;
346
+ });
347
+ if (k < 0)
348
+ return this.seek(target);
349
+ const [from, to] = moveWindows(this.rec, { maxAnimMs: this.maxAnimMs })[k];
350
+ this.animateStep(Math.min(time, to), from, target);
351
+ }
352
+ stepFrame = 0;
353
+ stepEnd = null;
354
+ /** Run the clock from `from` to `to` (recording ms) in real time, then settle on `end`. */
355
+ animateStep(from, to, end) {
356
+ const clock = this.clock;
357
+ if (!clock)
358
+ return;
359
+ clock.seek(from);
360
+ // At least a quarter second: a step is for looking at the move.
361
+ const duration = Math.max(250, Math.abs(to - from) / (this._rate || 1));
362
+ const started = performance.now();
363
+ this.stepEnd = end;
364
+ const tick = () => {
365
+ const k = Math.min(1, (performance.now() - started) / duration);
366
+ if (k >= 1)
367
+ return this.finishStep();
368
+ clock.seek(from + (to - from) * k);
369
+ this.stepFrame = requestAnimationFrame(tick);
370
+ };
371
+ this.stepFrame = requestAnimationFrame(tick);
372
+ }
373
+ /** A step still animating: jump to where it ends (another step, or anything else, comes next). */
374
+ finishStep() {
375
+ if (this.stepEnd === null)
376
+ return;
377
+ const end = this.stepEnd;
378
+ this.stopStep();
379
+ this.clock?.seek(end);
380
+ }
381
+ stopStep() {
382
+ cancelAnimationFrame(this.stepFrame);
383
+ this.stepEnd = null;
325
384
  }
326
385
  toStart() {
327
386
  this.pause();
@@ -400,6 +459,7 @@ export class CubePlayer extends ElementBase {
400
459
  this.clock?.pause();
401
460
  this.unsubscribe?.();
402
461
  let maxAnimMs = 150;
462
+ this.stopStep();
403
463
  let solves = [];
404
464
  try {
405
465
  if (this._recording) {
@@ -426,6 +486,7 @@ export class CubePlayer extends ElementBase {
426
486
  this.start = this._setupState ?? solvedState();
427
487
  this.dispatchEvent(new CustomEvent("error", { detail: { message: err instanceof Error ? err.message : String(err) } }));
428
488
  }
489
+ this.maxAnimMs = maxAnimMs;
429
490
  this.clock = new ReplayClock(this.rec, { maxAnimMs });
430
491
  this.clock.rate = this._rate;
431
492
  this.unsubscribe = this.clock.onChange((time, pos) => this.onTime(time, pos));
@@ -552,11 +613,22 @@ export class CubePlayer extends ElementBase {
552
613
  }
553
614
  const s = this.segs[index];
554
615
  tip.innerHTML = "";
555
- const text = document.createElement("span");
556
- text.className = "tip";
557
- text.setAttribute("part", "tooltip-text");
558
- text.textContent = this.formatSegment(s);
559
- tip.appendChild(text);
616
+ const line = (cls, part, content) => {
617
+ const el = document.createElement("span");
618
+ el.className = cls;
619
+ el.setAttribute("part", part);
620
+ el.textContent = content;
621
+ tip.appendChild(el);
622
+ };
623
+ if (this.formatSegment === segmentText) {
624
+ // The default: a heading and a quieter line of details.
625
+ const { title, details } = segmentLines(s);
626
+ line("tip tip-title", "tooltip-text tooltip-title", title);
627
+ if (details)
628
+ line("tip tip-details", "tooltip-text tooltip-details", details);
629
+ }
630
+ else
631
+ line("tip", "tooltip-text", this.formatSegment(s));
560
632
  tip.hidden = false;
561
633
  const track = this.$(".track").getBoundingClientRect();
562
634
  const centre = ((s.start + s.end) / 2 / (this.duration || 1)) * track.width;
@@ -12,7 +12,7 @@
12
12
  * - ::part() for anything else: stage, flat (the 2D picture), progress, progress-track,
13
13
  * progress-fill, progress-thumb, progress-marker, segment (+ segment-<id>),
14
14
  * segment-played, segment-recognition, segment-labels, segment-label
15
- * (+ segment-label-<id>), tooltip, tooltip-text, controls, buttons, button,
15
+ * (+ segment-label-<id>), tooltip, tooltip-text (tooltip-title, tooltip-details), controls, buttons, button,
16
16
  * button-start, button-back, button-play, button-forward, button-end,
17
17
  * button-speed, time.
18
18
  * - Replace the controls entirely: put your own element in slot="controls"
@@ -20,13 +20,13 @@
20
20
  *
21
21
  * Colours derive from `currentColor`, so the defaults fit light and dark pages.
22
22
  */
23
- export declare const STYLES = "\n:host {\n --cc-accent: #4f8cff;\n --cc-control-bg: color-mix(in srgb, currentColor 8%, transparent);\n --cc-control-bg-hover: color-mix(in srgb, currentColor 15%, transparent);\n --cc-control-fg: currentColor;\n --cc-control-radius: 10px;\n --cc-button-size: 34px;\n --cc-controls-gap: 5px;\n --cc-progress-height: 6px;\n --cc-progress-track: color-mix(in srgb, currentColor 14%, transparent);\n --cc-progress-fill: var(--cc-accent);\n --cc-marker: color-mix(in srgb, currentColor 55%, transparent);\n --cc-thumb-size: 14px;\n --cc-font: ui-monospace, \"SF Mono\", Menlo, monospace;\n /* sections of the progress bar */\n --cc-segment-1: #4f8cff;\n --cc-segment-2: #22c55e;\n --cc-segment-3: #f59e0b;\n --cc-segment-4: #ef4444;\n --cc-segment-5: #a855f7;\n --cc-segment-6: #14b8a6;\n --cc-segment-7: #ec4899;\n --cc-segment-8: #eab308;\n --cc-segment-height: calc(var(--cc-progress-height) + 2px);\n --cc-segment-gap: 2px;\n --cc-segment-unplayed: 30%;\n /* popup: inverted page colours by default (text colour as background) */\n --cc-tooltip-bg: color-mix(in srgb, currentColor 92%, transparent);\n --cc-tooltip-fg: Canvas;\n display: flex;\n flex-direction: column;\n gap: 10px;\n min-width: 200px;\n min-height: 200px;\n outline: none;\n container-type: inline-size;\n}\n:host([hidden]) { display: none; }\n.stage { position: relative; flex: 1; min-height: 0; }\n.flat { position: absolute; inset: 0; display: grid; place-items: center; }\n.flat[hidden] { display: none; }\n.flat svg { width: 100%; height: 100%; }\n\n/* \u2500\u2500\u2500 progress bar \u2500\u2500\u2500 */\n.progress { display: none; padding: 6px 2px; cursor: pointer; touch-action: none; }\n:host([progress]) .progress { display: block; }\n.track {\n position: relative;\n height: var(--cc-progress-height);\n border-radius: 999px;\n background: var(--cc-progress-track);\n}\n.fill {\n position: absolute; inset: 0 auto 0 0;\n width: 0;\n border-radius: inherit;\n background: var(--cc-progress-fill);\n}\n.thumb {\n position: absolute; top: 50%; left: 0;\n width: var(--cc-thumb-size); height: var(--cc-thumb-size);\n border-radius: 50%;\n background: var(--cc-progress-fill);\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--cc-progress-fill) 25%, transparent);\n transform: translate(-50%, -50%) scale(0.85);\n transition: transform 0.12s ease;\n}\n.progress:hover .thumb, .progress.dragging .thumb { transform: translate(-50%, -50%) scale(1); }\n/* Sections: coloured parts of the bar, dim until played, recognition hatched. */\n.progress { position: relative; }\n.progress.has-segments .fill { display: none; }\n.segments { position: absolute; inset: 0; }\n.segment {\n position: absolute; top: 50%;\n height: var(--cc-segment-height);\n transform: translateY(-50%);\n overflow: hidden;\n background: color-mix(in srgb, var(--seg) var(--cc-segment-unplayed), transparent);\n clip-path: inset(0 calc(var(--cc-segment-gap) / 2) 0 calc(var(--cc-segment-gap) / 2) round 999px);\n}\n.segment .played { position: absolute; inset: 0 auto 0 0; width: 0; background: var(--seg); }\n.segment .recognition {\n position: absolute; inset: 0 auto 0 0;\n background: repeating-linear-gradient(135deg, color-mix(in srgb, Canvas 50%, transparent) 0 2px, transparent 2px 5px);\n}\n.progress.has-segments .thumb { background: currentColor; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 20%, transparent); }\n.tooltip {\n position: absolute; bottom: calc(100% + 4px); left: 0;\n transform: translateX(-50%);\n width: max-content; max-width: min(320px, 100%);\n padding: 6px 9px;\n border-radius: 8px;\n /* colour stays the page's text colour here, so the background can be derived from it\u2026 */\n background: var(--cc-tooltip-bg);\n font: 500 12px/1.35 system-ui, sans-serif;\n pointer-events: none;\n z-index: 2;\n box-shadow: 0 6px 18px -8px rgb(0 0 0 / 0.5);\n}\n/* \u2026and the text inverts it. */\n.tooltip .tip { color: var(--cc-tooltip-fg); }\n.tooltip[hidden] { display: none; }\n.labels { display: none; position: relative; height: 18px; margin-top: -4px; }\n:host([segment-labels]) .labels { display: block; }\n.label {\n position: absolute; top: 0;\n min-width: 0; height: 18px; padding: 0 2px;\n background: none; border-radius: 4px;\n color: inherit; opacity: 0.6;\n font: 500 11px/18px system-ui, sans-serif;\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n}\n.label:hover { background: var(--cc-control-bg); opacity: 1; transform: none; }\n.label:active { transform: none; }\n.label.current { opacity: 1; font-weight: 700; }\n.marker {\n position: absolute; top: 50%;\n width: 2px; height: calc(var(--cc-progress-height) + 8px);\n border-radius: 1px;\n background: var(--cc-marker);\n transform: translate(-50%, -50%);\n pointer-events: none;\n}\n:host(:not([markers])) .marker { display: none; }\n.progress:focus-visible .track { outline: 2px solid var(--cc-accent); outline-offset: 4px; }\n\n/* \u2500\u2500\u2500 controls \u2500\u2500\u2500 */\n/* time | buttons | speed \u2014 the buttons stay centred whatever the time text's width */\n.controls { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: var(--cc-controls-gap); }\n.buttons { display: flex; gap: var(--cc-controls-gap); }\n.time { justify-self: start; }\n.speed { justify-self: end; }\n/* Narrow players: buttons on top, time and speed below. */\n@container (max-width: 300px) {\n .controls { grid-template-columns: 1fr 1fr; grid-template-areas: \"buttons buttons\" \"time speed\"; }\n .buttons { grid-area: buttons; justify-content: center; }\n .time { grid-area: time; }\n .speed { grid-area: speed; }\n}\n:host([controls=\"none\"]) .controls { display: none; }\n/* Live (following a smart cube): nothing to replay. */\n:host([live]) .controls, :host([live]) .progress { display: none; }\nbutton {\n display: inline-grid; place-items: center;\n min-width: var(--cc-button-size); height: var(--cc-button-size);\n padding: 0 8px;\n border: 0;\n border-radius: var(--cc-control-radius);\n background: var(--cc-control-bg);\n color: var(--cc-control-fg);\n font: 600 12px/1 var(--cc-font);\n cursor: pointer;\n transition: background 0.12s ease, transform 0.08s ease;\n}\nbutton:hover { background: var(--cc-control-bg-hover); }\nbutton:active { transform: scale(0.94); }\nbutton:focus-visible { outline: 2px solid var(--cc-accent); outline-offset: 2px; }\nbutton:disabled { opacity: 0.4; cursor: default; transform: none; }\n/* Icons never take the click: it always lands on the button itself, even if the icon changes mid-click. */\nbutton svg { width: 18px; height: 18px; display: block; fill: currentColor; pointer-events: none; }\n.play { min-width: calc(var(--cc-button-size) * 1.25); background: var(--cc-accent); color: #fff; }\n.play:hover { background: color-mix(in srgb, var(--cc-accent) 85%, #000); }\n.time { font: 500 12px/1 var(--cc-font); opacity: 0.75; white-space: nowrap; font-variant-numeric: tabular-nums; }\n";
23
+ export declare const STYLES = "\n:host {\n --cc-accent: #4f8cff;\n --cc-control-bg: color-mix(in srgb, currentColor 8%, transparent);\n --cc-control-bg-hover: color-mix(in srgb, currentColor 15%, transparent);\n --cc-control-fg: currentColor;\n --cc-control-radius: 10px;\n --cc-button-size: 34px;\n --cc-controls-gap: 5px;\n --cc-progress-height: 6px;\n --cc-progress-track: color-mix(in srgb, currentColor 14%, transparent);\n --cc-progress-fill: var(--cc-accent);\n --cc-marker: color-mix(in srgb, currentColor 55%, transparent);\n --cc-thumb-size: 14px;\n --cc-font: ui-monospace, \"SF Mono\", Menlo, monospace;\n /* sections of the progress bar */\n --cc-segment-1: #4f8cff;\n --cc-segment-2: #22c55e;\n --cc-segment-3: #f59e0b;\n --cc-segment-4: #ef4444;\n --cc-segment-5: #a855f7;\n --cc-segment-6: #14b8a6;\n --cc-segment-7: #ec4899;\n --cc-segment-8: #eab308;\n --cc-segment-height: calc(var(--cc-progress-height) + 2px);\n --cc-segment-gap: 2px;\n --cc-segment-unplayed: 30%;\n /* popup: dark and solid by default \u2014 readable on light and dark pages and over the cube */\n --cc-tooltip-bg: rgb(17 20 28 / 0.97);\n --cc-tooltip-fg: #f4f5f7;\n --cc-tooltip-muted: #a9afbb;\n --cc-tooltip-border: rgb(255 255 255 / 0.12);\n display: flex;\n flex-direction: column;\n gap: 10px;\n min-width: 200px;\n min-height: 200px;\n outline: none;\n container-type: inline-size;\n}\n:host([hidden]) { display: none; }\n.stage { position: relative; flex: 1; min-height: 0; }\n.flat { position: absolute; inset: 0; display: grid; place-items: center; }\n.flat[hidden] { display: none; }\n.flat svg { width: 100%; height: 100%; }\n\n/* \u2500\u2500\u2500 progress bar \u2500\u2500\u2500 */\n.progress { display: none; padding: 6px 2px; cursor: pointer; touch-action: none; }\n:host([progress]) .progress { display: block; }\n.track {\n position: relative;\n height: var(--cc-progress-height);\n border-radius: 999px;\n background: var(--cc-progress-track);\n}\n.fill {\n position: absolute; inset: 0 auto 0 0;\n width: 0;\n border-radius: inherit;\n background: var(--cc-progress-fill);\n}\n.thumb {\n position: absolute; top: 50%; left: 0;\n width: var(--cc-thumb-size); height: var(--cc-thumb-size);\n border-radius: 50%;\n background: var(--cc-progress-fill);\n box-shadow: 0 0 0 3px color-mix(in srgb, var(--cc-progress-fill) 25%, transparent);\n transform: translate(-50%, -50%) scale(0.85);\n transition: transform 0.12s ease;\n}\n.progress:hover .thumb, .progress.dragging .thumb { transform: translate(-50%, -50%) scale(1); }\n/* Sections: coloured parts of the bar, dim until played, recognition hatched. */\n.progress { position: relative; }\n.progress.has-segments .fill { display: none; }\n.segments { position: absolute; inset: 0; }\n.segment {\n position: absolute; top: 50%;\n height: var(--cc-segment-height);\n transform: translateY(-50%);\n overflow: hidden;\n background: color-mix(in srgb, var(--seg) var(--cc-segment-unplayed), transparent);\n clip-path: inset(0 calc(var(--cc-segment-gap) / 2) 0 calc(var(--cc-segment-gap) / 2) round 999px);\n}\n.segment .played { position: absolute; inset: 0 auto 0 0; width: 0; background: var(--seg); }\n.segment .recognition {\n position: absolute; inset: 0 auto 0 0;\n background: repeating-linear-gradient(135deg, color-mix(in srgb, Canvas 50%, transparent) 0 2px, transparent 2px 5px);\n}\n.progress.has-segments .thumb { background: currentColor; box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 20%, transparent); }\n.tooltip {\n position: absolute; bottom: calc(100% + 4px); left: 0;\n transform: translateX(-50%);\n width: max-content; max-width: min(320px, 100%);\n padding: 6px 9px;\n border-radius: 8px;\n background: var(--cc-tooltip-bg);\n border: 1px solid var(--cc-tooltip-border);\n font: 500 12px/1.4 system-ui, sans-serif;\n pointer-events: none;\n z-index: 2;\n box-shadow: 0 8px 24px -8px rgb(0 0 0 / 0.6);\n display: flex; flex-direction: column; gap: 1px;\n}\n.tooltip .tip { color: var(--cc-tooltip-fg); }\n.tooltip .tip-title { font-weight: 650; font-variant-numeric: tabular-nums; }\n.tooltip .tip-details { color: var(--cc-tooltip-muted); font-size: 11px; font-variant-numeric: tabular-nums; }\n.tooltip[hidden] { display: none; }\n.labels { display: none; position: relative; height: 18px; margin-top: -4px; }\n:host([segment-labels]) .labels { display: block; }\n.label {\n position: absolute; top: 0;\n min-width: 0; height: 18px; padding: 0 2px;\n background: none; border-radius: 4px;\n color: inherit; opacity: 0.6;\n font: 500 11px/18px system-ui, sans-serif;\n overflow: hidden; text-overflow: ellipsis; white-space: nowrap;\n}\n.label:hover { background: var(--cc-control-bg); opacity: 1; transform: none; }\n.label:active { transform: none; }\n.label.current { opacity: 1; font-weight: 700; }\n.marker {\n position: absolute; top: 50%;\n width: 2px; height: calc(var(--cc-progress-height) + 8px);\n border-radius: 1px;\n background: var(--cc-marker);\n transform: translate(-50%, -50%);\n pointer-events: none;\n}\n:host(:not([markers])) .marker { display: none; }\n.progress:focus-visible .track { outline: 2px solid var(--cc-accent); outline-offset: 4px; }\n\n/* \u2500\u2500\u2500 controls \u2500\u2500\u2500 */\n/* time | buttons | speed \u2014 the buttons stay centred whatever the time text's width */\n.controls { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; gap: var(--cc-controls-gap); }\n.buttons { display: flex; gap: var(--cc-controls-gap); }\n.time { justify-self: start; }\n.speed { justify-self: end; }\n/* Narrow players: buttons on top, time and speed below. */\n@container (max-width: 300px) {\n .controls { grid-template-columns: 1fr 1fr; grid-template-areas: \"buttons buttons\" \"time speed\"; }\n .buttons { grid-area: buttons; justify-content: center; }\n .time { grid-area: time; }\n .speed { grid-area: speed; }\n}\n:host([controls=\"none\"]) .controls { display: none; }\n/* Live (following a smart cube): nothing to replay. */\n:host([live]) .controls, :host([live]) .progress { display: none; }\nbutton {\n display: inline-grid; place-items: center;\n min-width: var(--cc-button-size); height: var(--cc-button-size);\n padding: 0 8px;\n border: 0;\n border-radius: var(--cc-control-radius);\n background: var(--cc-control-bg);\n color: var(--cc-control-fg);\n font: 600 12px/1 var(--cc-font);\n cursor: pointer;\n transition: background 0.12s ease, transform 0.08s ease;\n}\nbutton:hover { background: var(--cc-control-bg-hover); }\nbutton:active { transform: scale(0.94); }\nbutton:focus-visible { outline: 2px solid var(--cc-accent); outline-offset: 2px; }\nbutton:disabled { opacity: 0.4; cursor: default; transform: none; }\n/* Icons never take the click: it always lands on the button itself, even if the icon changes mid-click. */\nbutton svg { width: 18px; height: 18px; display: block; fill: currentColor; pointer-events: none; }\n.play { min-width: calc(var(--cc-button-size) * 1.25); background: var(--cc-accent); color: #fff; }\n.play:hover { background: color-mix(in srgb, var(--cc-accent) 85%, #000); }\n.time { font: 500 12px/1 var(--cc-font); opacity: 0.75; white-space: nowrap; font-variant-numeric: tabular-nums; }\n";
24
24
  /** Inline icons (no icon font, no network). */
25
25
  export declare const ICONS: {
26
- readonly start: "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M6 5h2v14H6zM20 5.5v13a.5.5 0 0 1-.78.42L9.5 12.4a.5.5 0 0 1 0-.8l9.72-6.52A.5.5 0 0 1 20 5.5z\"/></svg>";
27
- readonly back: "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M17 6.2v11.6a.5.5 0 0 1-.8.4L8 12.4a.5.5 0 0 1 0-.8l8.2-5.8a.5.5 0 0 1 .8.4zM6 6h2v12H6z\"/></svg>";
26
+ readonly start: "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M4 5h2v14H4zM13 6.3v11.4a.5.5 0 0 1-.8.4L6.9 12.4a.5.5 0 0 1 0-.8l5.3-5.7a.5.5 0 0 1 .8.4zM20.5 6.3v11.4a.5.5 0 0 1-.8.4l-5.3-5.7a.5.5 0 0 1 0-.8l5.3-5.7a.5.5 0 0 1 .8.4z\"/></svg>";
27
+ readonly back: "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M16 6.2v11.6a.5.5 0 0 1-.8.4l-7.7-5.8a.5.5 0 0 1 0-.8l7.7-5.8a.5.5 0 0 1 .8.4z\"/></svg>";
28
28
  readonly play: "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M8 5.6v12.8a.6.6 0 0 0 .92.5l10.1-6.4a.6.6 0 0 0 0-1L8.92 5.1a.6.6 0 0 0-.92.5z\"/></svg>";
29
29
  readonly pause: "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><rect x=\"6.5\" y=\"5\" width=\"4\" height=\"14\" rx=\"1\"/><rect x=\"13.5\" y=\"5\" width=\"4\" height=\"14\" rx=\"1\"/></svg>";
30
- readonly forward: "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M7 6.2v11.6a.5.5 0 0 0 .8.4l8.2-5.8a.5.5 0 0 0 0-.8L7.8 5.8a.5.5 0 0 0-.8.4zM16 6h2v12h-2z\"/></svg>";
31
- readonly end: "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M16 5h2v14h-2zM4 5.5v13a.5.5 0 0 0 .78.42l9.72-6.52a.5.5 0 0 0 0-.8L4.78 5.08A.5.5 0 0 0 4 5.5z\"/></svg>";
30
+ readonly forward: "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M8 6.2v11.6a.5.5 0 0 0 .8.4l7.7-5.8a.5.5 0 0 0 0-.8L8.8 5.8a.5.5 0 0 0-.8.4z\"/></svg>";
31
+ readonly end: "<svg viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M18 5h2v14h-2zM3.5 6.3v11.4a.5.5 0 0 0 .8.4l5.3-5.7a.5.5 0 0 0 0-.8L4.3 5.9a.5.5 0 0 0-.8.4zM11 6.3v11.4a.5.5 0 0 0 .8.4l5.3-5.7a.5.5 0 0 0 0-.8l-5.3-5.7a.5.5 0 0 0-.8.4z\"/></svg>";
32
32
  };
package/element/styles.js CHANGED
@@ -12,7 +12,7 @@
12
12
  * - ::part() for anything else: stage, flat (the 2D picture), progress, progress-track,
13
13
  * progress-fill, progress-thumb, progress-marker, segment (+ segment-<id>),
14
14
  * segment-played, segment-recognition, segment-labels, segment-label
15
- * (+ segment-label-<id>), tooltip, tooltip-text, controls, buttons, button,
15
+ * (+ segment-label-<id>), tooltip, tooltip-text (tooltip-title, tooltip-details), controls, buttons, button,
16
16
  * button-start, button-back, button-play, button-forward, button-end,
17
17
  * button-speed, time.
18
18
  * - Replace the controls entirely: put your own element in slot="controls"
@@ -47,9 +47,11 @@ export const STYLES = /* css */ `
47
47
  --cc-segment-height: calc(var(--cc-progress-height) + 2px);
48
48
  --cc-segment-gap: 2px;
49
49
  --cc-segment-unplayed: 30%;
50
- /* popup: inverted page colours by default (text colour as background) */
51
- --cc-tooltip-bg: color-mix(in srgb, currentColor 92%, transparent);
52
- --cc-tooltip-fg: Canvas;
50
+ /* popup: dark and solid by default — readable on light and dark pages and over the cube */
51
+ --cc-tooltip-bg: rgb(17 20 28 / 0.97);
52
+ --cc-tooltip-fg: #f4f5f7;
53
+ --cc-tooltip-muted: #a9afbb;
54
+ --cc-tooltip-border: rgb(255 255 255 / 0.12);
53
55
  display: flex;
54
56
  flex-direction: column;
55
57
  gap: 10px;
@@ -113,15 +115,17 @@ export const STYLES = /* css */ `
113
115
  width: max-content; max-width: min(320px, 100%);
114
116
  padding: 6px 9px;
115
117
  border-radius: 8px;
116
- /* colour stays the page's text colour here, so the background can be derived from it… */
117
118
  background: var(--cc-tooltip-bg);
118
- font: 500 12px/1.35 system-ui, sans-serif;
119
+ border: 1px solid var(--cc-tooltip-border);
120
+ font: 500 12px/1.4 system-ui, sans-serif;
119
121
  pointer-events: none;
120
122
  z-index: 2;
121
- box-shadow: 0 6px 18px -8px rgb(0 0 0 / 0.5);
123
+ box-shadow: 0 8px 24px -8px rgb(0 0 0 / 0.6);
124
+ display: flex; flex-direction: column; gap: 1px;
122
125
  }
123
- /* …and the text inverts it. */
124
126
  .tooltip .tip { color: var(--cc-tooltip-fg); }
127
+ .tooltip .tip-title { font-weight: 650; font-variant-numeric: tabular-nums; }
128
+ .tooltip .tip-details { color: var(--cc-tooltip-muted); font-size: 11px; font-variant-numeric: tabular-nums; }
125
129
  .tooltip[hidden] { display: none; }
126
130
  .labels { display: none; position: relative; height: 18px; margin-top: -4px; }
127
131
  :host([segment-labels]) .labels { display: block; }
@@ -187,10 +191,11 @@ button svg { width: 18px; height: 18px; display: block; fill: currentColor; poin
187
191
  `;
188
192
  /** Inline icons (no icon font, no network). */
189
193
  export const ICONS = {
190
- start: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 5h2v14H6zM20 5.5v13a.5.5 0 0 1-.78.42L9.5 12.4a.5.5 0 0 1 0-.8l9.72-6.52A.5.5 0 0 1 20 5.5z"/></svg>',
191
- back: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17 6.2v11.6a.5.5 0 0 1-.8.4L8 12.4a.5.5 0 0 1 0-.8l8.2-5.8a.5.5 0 0 1 .8.4zM6 6h2v12H6z"/></svg>',
194
+ // To the start / end: two triangles against a bar; one move back / on: a single triangle (as cubing.js).
195
+ start: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 5h2v14H4zM13 6.3v11.4a.5.5 0 0 1-.8.4L6.9 12.4a.5.5 0 0 1 0-.8l5.3-5.7a.5.5 0 0 1 .8.4zM20.5 6.3v11.4a.5.5 0 0 1-.8.4l-5.3-5.7a.5.5 0 0 1 0-.8l5.3-5.7a.5.5 0 0 1 .8.4z"/></svg>',
196
+ back: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 6.2v11.6a.5.5 0 0 1-.8.4l-7.7-5.8a.5.5 0 0 1 0-.8l7.7-5.8a.5.5 0 0 1 .8.4z"/></svg>',
192
197
  play: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.6v12.8a.6.6 0 0 0 .92.5l10.1-6.4a.6.6 0 0 0 0-1L8.92 5.1a.6.6 0 0 0-.92.5z"/></svg>',
193
198
  pause: '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="6.5" y="5" width="4" height="14" rx="1"/><rect x="13.5" y="5" width="4" height="14" rx="1"/></svg>',
194
- forward: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M7 6.2v11.6a.5.5 0 0 0 .8.4l8.2-5.8a.5.5 0 0 0 0-.8L7.8 5.8a.5.5 0 0 0-.8.4zM16 6h2v12h-2z"/></svg>',
195
- end: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M16 5h2v14h-2zM4 5.5v13a.5.5 0 0 0 .78.42l9.72-6.52a.5.5 0 0 0 0-.8L4.78 5.08A.5.5 0 0 0 4 5.5z"/></svg>',
199
+ forward: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 6.2v11.6a.5.5 0 0 0 .8.4l7.7-5.8a.5.5 0 0 0 0-.8L8.8 5.8a.5.5 0 0 0-.8.4z"/></svg>',
200
+ end: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 5h2v14h-2zM3.5 6.3v11.4a.5.5 0 0 0 .8.4l5.3-5.7a.5.5 0 0 0 0-.8L4.3 5.9a.5.5 0 0 0-.8.4zM11 6.3v11.4a.5.5 0 0 0 .8.4l5.3-5.7a.5.5 0 0 0 0-.8l-5.3-5.7a.5.5 0 0 0-.8.4z"/></svg>',
196
201
  };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.1.0",
6
+ "version": "0.1.2",
7
7
  "description": "A 3×3×3 cube library for the web, built around smart (Bluetooth) cubes: state, notation, methods, solvers and scrambles, a three.js renderer with skins, pictures, web components, React bindings.",
8
8
  "license": "MPL-2.0",
9
9
  "author": "Tomasz Wodzikowski <wodziszczakow@gmail.com>",
package/solve/twophase.js CHANGED
@@ -79,6 +79,54 @@ function moveTable(n, make, coord, moves) {
79
79
  }
80
80
  return t;
81
81
  }
82
+ /**
83
+ * A move table over permutations of `n` pieces, straight on ranks: for each
84
+ * rank, the permutation once, then per move `next[i] = perm[map[i]]` (map =
85
+ * where the move takes each position from, restricted to these `n` pieces)
86
+ * and its rank — no objects per entry (8! × 18 entries: this is most of the
87
+ * build time otherwise).
88
+ */
89
+ function permMoveTable(n, maps) {
90
+ const count = maps.length;
91
+ let size = 1;
92
+ for (let i = 2; i <= n; i++)
93
+ size *= i;
94
+ const t = new Uint16Array(size * count);
95
+ const perm = new Int8Array(n), next = new Int8Array(n), pool = new Int8Array(n);
96
+ for (let r = 0; r < size; r++) {
97
+ // unrank (Lehmer code, same order as core permRank / permUnrank)
98
+ let rank = r;
99
+ for (let i = 0; i < n; i++)
100
+ pool[i] = i;
101
+ let left = n;
102
+ for (let i = 0; i < n; i++) {
103
+ let f = 1;
104
+ for (let k = 2; k < n - i; k++)
105
+ f *= k;
106
+ const d = Math.floor(rank / f);
107
+ rank -= d * f;
108
+ perm[i] = pool[d];
109
+ for (let k = d; k < left - 1; k++)
110
+ pool[k] = pool[k + 1];
111
+ left--;
112
+ }
113
+ for (let m = 0; m < count; m++) {
114
+ const map = maps[m];
115
+ for (let i = 0; i < n; i++)
116
+ next[i] = perm[map[i]];
117
+ let out = 0;
118
+ for (let i = 0; i < n; i++) {
119
+ let smaller = 0;
120
+ for (let j = i + 1; j < n; j++)
121
+ if (next[j] < next[i])
122
+ smaller++;
123
+ out = out * (n - i) + smaller;
124
+ }
125
+ t[r * count + m] = out;
126
+ }
127
+ }
128
+ return t;
129
+ }
82
130
  /** Breadth-first distances over a product of two coordinates. */
83
131
  function pruneTable(n1, n2, move1, move2, moveCount, start) {
84
132
  const dist = new Int8Array(n1 * n2).fill(-1);
@@ -104,8 +152,9 @@ function buildTables() {
104
152
  const twistMove = moveTable(N_TWIST, withTwist, twistOf, all18);
105
153
  const flipMove = moveTable(N_FLIP, withFlip, flipOf, all18);
106
154
  const sliceMove = moveTable(N_SLICE, withSlice, sliceOf, all18);
107
- const cornerMove = moveTable(N_PERM8, (i) => ({ ...SOLVED_CC, cp: permUnrank(i, 8) }), (c) => permRank(c.cp), all18);
108
- const udEdgeMove = moveTable(N_PERM8, (i) => ({ ...SOLVED_CC, ep: [...permUnrank(i, 8), 8, 9, 10, 11] }), (c) => permRank(c.ep.slice(0, 8)), P2_MOVES);
155
+ const cornerMove = permMoveTable(8, all18.map((m) => MOVE_CC[m].cp));
156
+ // P2 moves keep the U / D edges (positions 0–7) among themselves.
157
+ const udEdgeMove = permMoveTable(8, P2_MOVES.map((m) => MOVE_CC[m].ep.slice(0, 8)));
109
158
  const slicePermMove = Uint8Array.from(moveTable(N_SLICEPERM, (i) => ({ ...SOLVED_CC, ep: [0, 1, 2, 3, 4, 5, 6, 7, ...permUnrank(i, 4).map((e) => e + 8)] }), (c) => permRank(c.ep.slice(8).map((e) => e - 8)), P2_MOVES));
110
159
  // Phase-2 pruning walks P2 moves: pick the corner-table columns for them.
111
160
  const cornerP2 = new Uint16Array(N_PERM8 * P2_MOVES.length);
@@ -17,7 +17,8 @@ export function stageTimings(method, rec) {
17
17
  // A move's timestamp is when it completed; its start is ~the previous move's time.
18
18
  const firstStart = moveCount > 0 ? (prevIndex === 0 ? 0 : rec.moves[prevIndex - 1].t) : endMs;
19
19
  const firstDone = moveCount > 0 ? rec.moves[prevIndex].t : endMs;
20
- const recognitionMs = moveCount > 0 ? Math.max(0, firstDone - firstStart - estimateTurnMs(rec)) : 0;
20
+ // AUF: a U turn or two — nothing to recognise, the whole stage is turning.
21
+ const recognitionMs = moveCount > 0 && b.stage !== "auf" ? Math.max(0, firstDone - firstStart - estimateTurnMs(rec)) : 0;
21
22
  const t = {
22
23
  stage: b.stage,
23
24
  label: labels.get(b.stage) ?? b.stage,