Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions examples/wikirace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,13 @@ The harness has the `view` command of switch's [viewer](../../viewer/README.md):
examples/wikirace/src/main.ts view runs/wikirace
```

The wikirace part of the page, in [`src/web/`](src/web), draws each step's trail of articles from the start, the
current one lit, toward the target, and says which section the step showed. The viewer shows the step's choices with
the model's probability for each link.
The wikirace part of the page, in [`src/web/`](src/web), looks like Wikipedia, in its light or night colors as the
system asks. At the left it draws the route of the articles so far, like the stops of a train line, with the target
at its end. At the right it draws the step's branches from the article the race is at: the eight likeliest links,
sections and giving up, each with a bar for the model's probability, and a curve to each that is thicker for a likelier
branch. The picked branch is marked, and it is always among the eight. Beside the drawing, the list of steps says
where each step went and how likely the model found it. Each article is a link to Wikipedia. A section's choice keeps
its name and its number of links in its data, so the page can name it.

## Check

Expand Down
6 changes: 5 additions & 1 deletion examples/wikirace/src/race.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,18 @@ const link = (wiki: Wiki) =>
const sampleOf = (links: readonly string[]): string =>
links.length <= 3 ? links.join(", ") : `${links.slice(0, 3).join(", ")} and ${String(links.length - 3)} more`;

/** Open a section of an article with more links than one step shows, to see its links; each section at most once. */
/**
* Open a section of an article with more links than one step shows, to see its links; each section at most once. Its
* data names the section and counts its links, for the viewer.
*/
const section = choiceType<RaceState, number>({
type: "section",
choices: (state) =>
shownSections(state).map(({ index, name, links }) => ({
key: `${String(state.path.length)}:${String(index)}`,
text: `Open section ${String(index)}, "${name}": ${sampleOf(links)}`,
arg: index,
data: { section: name, links: links.length },
})),
transition: (state, index) => ({
...state,
Expand Down
7 changes: 5 additions & 2 deletions examples/wikirace/src/viewer.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { type DomainViewer, modulesNear } from "@osolmaz/switch-viewer";

/** The wikirace part of the viewer: the trail of articles toward the target, with its styles. */
/**
* The wikirace part of the viewer: the route of the articles toward the target, the branches of each step with the
* model's probabilities, and the list of the steps, with styles that look like Wikipedia.
*/
export const wikiraceViewer: DomainViewer = {
modules: modulesNear(import.meta.url, "./web", ["part"]),
modules: modulesNear(import.meta.url, "./web", ["data", "route", "part"]),
styles: [new URL("./web/wikirace.css", import.meta.url)],
summary: ({ result, hops }) =>
[result, typeof hops === "number" ? `${String(hops)} hops` : null]
Expand Down
132 changes: 132 additions & 0 deletions examples/wikirace/src/web/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* What the wikirace part reads of a case: a race in each state, and the branches of each step's pick, which are links,
* sections to open and giving up.
*/
import { type FrameChoice, type Moment, pickOf, ranked, type Session } from "@osolmaz/switch-viewer/page";

/** One article of the path, and the text of the link that led to it. */
export interface RaceVisit {
readonly title: string;
readonly via: string;
}

/** What the part reads of a race's state; anything else in it stays in the state's JSON. */
export interface RaceView {
readonly target: string;
readonly path: readonly RaceVisit[];
/** The section whose links the state shows; null when it shows every link of the article. */
readonly section: string | null;
readonly gaveUp: boolean;
/** What the last step did, in words. */
readonly last: string;
}

const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
typeof value === "object" && value !== null && !Array.isArray(value);

const visitsOf = (path: readonly unknown[]): RaceVisit[] =>
path.flatMap((visit) =>
isRecord(visit) && typeof visit["title"] === "string"
? [{ title: visit["title"], via: typeof visit["via"] === "string" ? visit["via"] : visit["title"] }]
: [],
);

/** The name of the section that a state shows; null when it shows every link. */
function openSection(state: Readonly<Record<string, unknown>>): string | null {
const sections = Array.isArray(state["sections"]) ? state["sections"] : [];
const open: unknown = typeof state["open"] === "number" ? sections[state["open"]] : null;
return isRecord(open) && typeof open["name"] === "string" ? open["name"] : null;
}

/** The race of a moment's state; null when the state is not a race's. */
export function raceOf(state: unknown): RaceView | null {
if (!isRecord(state) || typeof state["target"] !== "string" || !Array.isArray(state["path"])) return null;
return {
target: state["target"],
path: visitsOf(state["path"]),
section: openSection(state),
gaveUp: state["gaveUp"] === true,
last: typeof state["last"] === "string" ? state["last"] : "",
};
}

/** Wikipedia titles match whatever the case of their first letter. */
export const sameArticle = (a: string, b: string): boolean =>
a.charAt(0).toUpperCase() + a.slice(1) === b.charAt(0).toUpperCase() + b.slice(1);

/** The article the race is at. */
export const raceHere = (race: RaceView): string => race.path.at(-1)?.title ?? "";

export const raceReached = (race: RaceView): boolean => sameArticle(raceHere(race), race.target);

/** The hops between articles so far. */
export const raceHops = (race: RaceView): number => Math.max(0, race.path.length - 1);

/** One branch of a step's pick: a link to follow, a section to open, or giving up. */
export interface RaceBranch {
/** The choice's type: "link", "section" or "give-up". */
readonly type: string;
/** The link's title, the section's name, or the text of giving up. */
readonly name: string;
/** For a section, how many links it has; null for the others. */
readonly links: number | null;
readonly probability: number | null;
readonly picked: boolean;
}

/** What the part reads of one choice of a pick. */
export function raceBranch(choice: FrameChoice): RaceBranch {
const { data } = choice;
const section = choice.type === "section" && isRecord(data) && typeof data["section"] === "string";
return {
type: choice.type ?? "link",
name: section ? (data["section"] as string) : choice.text,
links: section && typeof data["links"] === "number" ? data["links"] : null,
probability: choice.probability,
picked: choice.picked,
};
}

/** The branches of a step that the drawing shows, and how many choices of its pick it leaves out or the limit cut. */
export interface RaceFork {
readonly shown: readonly RaceBranch[];
readonly total: number;
readonly cut: number;
readonly answered: boolean;
}

/**
* A step's likeliest branches, at most `count`, with the picked one always among them, or the first `count` in the
* article's order before the model answered; null for a moment without a pick.
*/
export function raceFork(moment: Moment, count: number): RaceFork | null {
const pick = pickOf(moment.frame);
if (pick === null) return null;
const all = ranked(pick.choices);
const shown = all.slice(0, count);
const picked = all.find((choice) => choice.picked);
if (picked !== undefined && !shown.includes(picked)) shown[shown.length - 1] = picked;
return { shown: shown.map(raceBranch), total: all.length, cut: pick.cut, answered: pick.answer !== null };
}

/** One step of a race, for the list of steps: the article it started at and the branch the model picked. */
export interface RaceStep {
/** The moment that shows the step. */
readonly moment: number;
readonly from: string;
/** The picked branch; null before the model answered, or when the step failed. */
readonly picked: RaceBranch | null;
}

/** Every step of a race, in order. */
export function raceSteps(session: Session): RaceStep[] {
return session.story.frames.map((frame, index) => {
const choice = pickOf(frame)?.choices.find((offered) => offered.picked);
const before = raceOf(frame.before);
return {
moment: index + 1,
from: before === null ? "" : raceHere(before),
picked: choice === undefined ? null : raceBranch(choice),
};
});
}
108 changes: 64 additions & 44 deletions examples/wikirace/src/web/part.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,78 @@
/// <reference lib="dom" />
/** The wikirace part of the page: the trail of articles from the start toward the target, and what each step showed. */
import { type DomainPart, h, type Moment, registerPart } from "@osolmaz/switch-viewer/page";
/**
* The wikirace part of the page: the race toward its target, with the route so far and the branches of each step, and
* the list of the race's steps.
*/
import {
button,
type DomainPart,
h,
type Moment,
type PartContext,
percent,
registerPart,
type Session,
} from "@osolmaz/switch-viewer/page";
import { type RaceBranch, raceHere, raceOf, type RaceStep, raceSteps } from "./data.ts";
import { raceDrawing } from "./route.ts";

/** What the part reads of a race's state; anything else in it stays in the state's JSON. */
interface RaceTrail {
readonly target: string;
readonly titles: readonly string[];
readonly section: string | null;
/** Where a step went: the article, the section it opened, or giving up. */
function whereTo(picked: RaceBranch | null): string {
if (picked === null) return "…";
if (picked.type === "section") return `§ ${picked.name}`;
return picked.type === "give-up" ? "gave up" : picked.name;
}

const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
typeof value === "object" && value !== null;

/** The trail of a moment's state; null when the state is not a race's. */
export function raceTrailOf(state: unknown): RaceTrail | null {
if (!isRecord(state) || typeof state["target"] !== "string" || !Array.isArray(state["path"])) return null;
const titles = state["path"].flatMap((visit) =>
isRecord(visit) && typeof visit["title"] === "string" ? [visit["title"]] : [],
function stepButton(step: RaceStep, current: boolean, open: (moment: number) => void): HTMLElement {
const to = whereTo(step.picked);
const odds = step.picked === null ? "" : percent(step.picked.probability);
const node = button(
`Step ${String(step.moment)}: from ${step.from} to ${to}`,
`race-step${current ? " current" : ""}`,
"",
);
// The article a step left is the one the step before went to, so the list shows only where each step went; the
// label, which is also the title, names both.
node.append(
h("span", { class: "race-n" }, String(step.moment)),
h("span", { class: "race-dest" }, to),
h("small", {}, odds),
);
const sections = Array.isArray(state["sections"]) ? state["sections"] : [];
const open = typeof state["open"] === "number" ? (sections[state["open"]] as unknown) : null;
const section = isRecord(open) && typeof open["name"] === "string" ? open["name"] : null;
return { target: state["target"], titles, section };
if (current) node.setAttribute("aria-current", "step");
node.addEventListener("click", () => {
open(step.moment);
});
return node;
}

/** The trail as a row of articles, the current one marked, and the target at its end. */
function raceTrail(trail: RaceTrail): HTMLElement {
const last = trail.titles.length - 1;
const reached = trail.titles[last] === trail.target;
const crumbs = trail.titles.map((title, index) =>
h("li", { class: index === last ? "race-here" : "race-been" }, title),
);
const goal = reached ? [] : [h("li", { class: "race-gap" }, "…"), h("li", { class: "race-goal" }, trail.target)];
const where = trail.section === null ? "" : `Showing the section "${trail.section}"`;
return h(
"div",
{ class: "race-area" },
h("p", { class: "race-label" }, reached ? `Reached ${trail.target}` : `Racing to ${trail.target}`),
h("ol", { class: "race-trail" }, ...crumbs, ...goal),
h("p", { class: "race-where" }, where),
);
/** The race's steps, each with the article it left, where it went and the model's probability; each opens its step. */
export function raceStepList(session: Session, index: number, open: (moment: number) => void): HTMLElement[] {
const steps = raceSteps(session);
if (steps.length === 0) return [];
const items = steps.map((step) => h("li", {}, stepButton(step, step.moment === index, open)));
return [h("ol", { class: "race-steps" }, ...items)];
}

/** The wikirace part: it draws the trail of each moment, and notes the article each step was at. */
export function racePart(): DomainPart {
/** The article a step was at, after the step's label. */
export function raceNote(moment: Moment): string {
const race = raceOf(moment.state);
return race === null ? "" : ` · at ${raceHere(race)}`;
}

/**
* The wikirace part: it draws each moment of the race. When the page steps forward, the article the step arrived at
* and the branches move in, for as long as one motion of the page takes.
*/
export function racePart(context: PartContext): DomainPart {
return {
draw(moment: Moment) {
const trail = raceTrailOf(moment.state);
return Promise.resolve(trail === null ? null : raceTrail(trail));
},
stepNote(moment: Moment) {
const here = raceTrailOf(moment.state)?.titles.at(-1);
return here === undefined ? "" : ` · at ${here}`;
draw(moment: Moment, from: Moment | null, session: Session) {
const race = raceOf(moment.state);
if (race === null) return Promise.resolve(null);
const motion = from === null ? 0 : context.motion();
return Promise.resolve(raceDrawing(moment, session, race, motion));
},
stepNote: raceNote,
steps: { title: "Steps", list: raceStepList },
hidden: ["path"],
};
}
Expand Down
Loading