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
17 changes: 17 additions & 0 deletions apps/web/src/map-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,23 @@ describe("mapModel", () => {
expect(mapModel(world, new Set(), null).portals).toEqual([]);
});

it("marks the rooms of the unit the player is in", () => {
const everywhere = new Set(demo.rooms.map(({ room }) => room.id));
const main = demo.rooms
.filter(({ room }) => room.cluster === "demo.ts::main")
.map(({ room }) => room.id);
expect(main.length).toBeGreaterThan(1);
const model = mapModel(demo, everywhere, main[0] ?? null, "demo.ts::main");
expect(
model.rooms.filter((room) => room.active).map((room) => room.id)
).toEqual(main);
expect(model.rooms.find((room) => room.id === "demo.ts")?.unit).toBe(
"demo.ts"
);
const none = mapModel(demo, everywhere, main[0] ?? null);
expect(none.rooms.some((room) => room.active)).toBe(false);
});

it("draws one area of a world of several: only its rooms", () => {
const { codeGraph } = codeGraphOf(examples.repo);
if (codeGraph === null) {
Expand Down
28 changes: 19 additions & 9 deletions apps/web/src/map-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ type MapRoom = {
readonly visited: boolean;
/** The room the player stands in. */
readonly current: boolean;
/** The function (cluster) it belongs to, or the room itself. */
readonly unit: string;
/** It belongs to the unit the player is in: the function they walk through. */
readonly active: boolean;
};

type MapDoor = {
Expand Down Expand Up @@ -95,7 +99,8 @@ const pointKey = ({ x, z }: Point): string => `${x.toFixed(3)},${z.toFixed(3)}`;
const mapModel = (
world: BuiltWorld,
visited: ReadonlySet<string>,
current: string | null
current: string | null,
activeUnit: string | null = null
): MapModel => {
const entered = world.rooms.filter(({ room }) => visited.has(room.id));
const seen = new Set(
Expand All @@ -106,14 +111,19 @@ const mapModel = (
);
const rooms = world.rooms
.filter(({ room }) => seen.has(room.id))
.map(({ room }): MapRoom => ({
id: room.id,
rect: roomBounds(room),
kind: room.kind,
lane: room.lane?.kind ?? null,
visited: visited.has(room.id),
current: room.id === current,
}));
.map(({ room }): MapRoom => {
const unit = room.cluster ?? room.id;
return {
id: room.id,
rect: roomBounds(room),
kind: room.kind,
lane: room.lane?.kind ?? null,
visited: visited.has(room.id),
current: room.id === current,
unit,
active: unit === activeUnit,
};
});
const doors = new Map<string, MapDoor>();
for (const { room, openings } of entered) {
for (const opening of openings) {
Expand Down
35 changes: 30 additions & 5 deletions apps/web/src/overview-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ type OverviewMapProps = {
readonly visited: ReadonlySet<string>;
/** The room the player stands in, or null between rooms. */
readonly current: string | null;
/** The function (or hub) the player is in: its rooms are outlined brighter. */
readonly activeUnit: string | null;
/** Where the player is; only the map re-renders when it moves. */
readonly feed: PoseFeed;
};
Expand All @@ -38,6 +40,21 @@ const roomFill = (room: MapRoom): string => {
return room.lane === null ? PLAIN : laneColour(room.lane);
};

/** The room stood in strongest, then the other rooms of its function. */
const strokeOpacity = (room: MapRoom): number => {
if (room.current) {
return 0.9;
}
return room.active ? 0.7 : 0.35;
};

const strokeWidth = (room: MapRoom): number => {
if (room.current) {
return 1.5;
}
return room.active ? 1.25 : 1;
};

/** A short bar on the wall, a little inside the room, along the frame. */
const portalBar = ({ point, normal }: MapPortal) => {
const x = point.x + normal.x * 0.2;
Expand All @@ -55,7 +72,13 @@ const portalBar = ({ point, normal }: MapPortal) => {
* above round the player, north up. Screen y is world z, so north (-Z) is
* already at the top.
*/
const OverviewMap = ({ world, visited, current, feed }: OverviewMapProps) => {
const OverviewMap = ({
world,
visited,
current,
activeUnit,
feed,
}: OverviewMapProps) => {
const entry =
world.areas.find((area) => area.id === world.entry) ?? world.areas[0];
const start =
Expand All @@ -67,8 +90,10 @@ const OverviewMap = ({ world, visited, current, feed }: OverviewMapProps) => {
const area = areaAt(world.areas, pose.position, null) ?? entry;
const model = useMemo(
() =>
area === undefined ? EMPTY_MODEL : mapModel(area.built, visited, current),
[area, visited, current]
area === undefined
? EMPTY_MODEL
: mapModel(area.built, visited, current, activeUnit),
[area, visited, current, activeUnit]
);
const { x, z } = pose.position;
const heading =
Expand All @@ -92,8 +117,8 @@ const OverviewMap = ({ world, visited, current, feed }: OverviewMapProps) => {
fill={roomFill(room)}
fillOpacity={room.kind === "corridor" ? 0.2 : 0.35}
stroke={PLAIN}
strokeOpacity={room.current ? 0.9 : 0.35}
strokeWidth={room.current ? 1.5 : 1}
strokeOpacity={strokeOpacity(room)}
strokeWidth={strokeWidth(room)}
vectorEffect="non-scaling-stroke"
/>
))}
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/source-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,14 @@ const SourcePanel = ({ view }: SourcePanelProps) => {
? `lines ${view.lines}`
: `${view.region} · lines ${view.lines}`}
</p>
{view.callers.length > 0 && (
<p className="text-amber-100/50">
called by {view.callers.join(" · ")}
</p>
)}
{view.callees.length > 0 && (
<p className="text-amber-100/50">calls {view.callees.join(" · ")}</p>
)}
<pre ref={codeRef} className="min-h-0 flex-1 overflow-hidden">
{view.code.map((line) => (
<div key={line.number} className="flex gap-3">
Expand Down
111 changes: 107 additions & 4 deletions apps/web/src/source-view.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { CodeGraph, FlowStep } from "@repo/code-graph";
import type { SourceFile } from "@repo/parser";
import { describe, expect, it } from "vitest";

import { examples } from "./examples";
Expand All @@ -7,15 +8,19 @@ import { sourceView } from "./source-view";
import type { SourceView } from "./source-view";
import { codeGraphOf } from "./world-from-code";

/** Parses an example or fails the test with the parser's message. */
const parsed = (name: "demo" | "loops") => {
const result = codeGraphOf(examples[name]);
/** Parses files or fails the test with the parser's message. */
const parsedFiles = (files: readonly SourceFile[]) => {
const result = codeGraphOf(files);
if (result.error !== null) {
throw new Error(`${name}: ${result.error}`);
throw new Error(`${files[0]?.path}: ${result.error}`);
}
return result;
};

/** Parses an example or fails the test with the parser's message. */
const parsed = (name: "demo" | "loops" | "portals" | "resolution") =>
parsedFiles(examples[name]);

/** The first top-level flow node of `fnId` of the given kind. */
const topLevel = (
graph: CodeGraph,
Expand Down Expand Up @@ -123,3 +128,101 @@ describe("sourceView", () => {
expectWholeLines(view, examples.loops[0].source);
});
});

describe("sourceView calls", () => {
const demo = parsed("demo");

it("lists a function's callers and callees", () => {
expect(
sourceView(demo.codeGraph, demo.sources, "demo.ts::getUser")
).toMatchObject({ callers: ["main()"], callees: ["loadSession()"] });
});

it("lists the function's calls in a room of its flow too", () => {
const fork = topLevel(demo.codeGraph, "demo.ts::main", "branch");
expect(sourceView(demo.codeGraph, demo.sources, fork.id)).toMatchObject({
callers: [],
callees: ["getUser()", "showDashboard()", "showLogin()"],
});
});

it("lists nothing for a hub", () => {
expect(sourceView(demo.codeGraph, demo.sources, "demo.ts")).toMatchObject({
callers: [],
callees: [],
});
});

it("marks a function calling itself", () => {
const portals = parsed("portals");
expect(
sourceView(portals.codeGraph, portals.sources, "portals.ts::countdown")
).toMatchObject({
callers: ["countdown() ↻", "main()"],
callees: ["countdown() ↻"],
});
});

it("names an ambiguous call's candidates as maybe", () => {
const resolution = parsed("resolution");
expect(
sourceView(
resolution.codeGraph,
resolution.sources,
"resolution.ts::main"
)?.callees
).toEqual([
"pick()",
"settle()",
"maybe Circle.render() | Square.render()",
]);
});

it("names the file of callers that share a name", () => {
const caller = {
source: 'import { run } from "./lib";\nexport function main() { run(); }',
};
const shared = parsedFiles([
{ path: "a.ts", ...caller },
{ path: "b.ts", ...caller },
{ path: "lib.ts", source: "export function run() {}" },
]);
expect(
sourceView(shared.codeGraph, shared.sources, "lib.ts::run")?.callers
).toEqual(["a.ts · main()", "b.ts · main()"]);
});

it("keeps five candidates of an ambiguous call and counts the rest", () => {
const shapes = ["A", "B", "C", "D", "E", "F", "G"];
const many = parsedFiles([
{
path: "many.ts",
source: [
...shapes.map((shape) => `class ${shape} {\n render() {}\n}`),
`function main(shape: ${shapes.join(" | ")}) {\n shape.render();\n}`,
].join("\n"),
},
]);
expect(
sourceView(many.codeGraph, many.sources, "many.ts::main")?.callees
).toEqual([
"maybe A.render() | B.render() | C.render() | D.render() | E.render() | +2",
]);
});

it("keeps five names and counts the rest", () => {
const names = ["a", "b", "c", "d", "e", "f", "g"];
const fan = parsedFiles([
{
path: "fan.ts",
source: [
`function main() {\n${names.map((name) => ` ${name}();`).join("\n")}\n}`,
...names.map((name) => `function ${name}() {}`),
].join("\n"),
},
]);
expect(
sourceView(fan.codeGraph, fan.sources, "fan.ts::main")?.callees
).toEqual(["a()", "b()", "c()", "d()", "e()", "+2"]);
});
});
Loading
Loading