@ntmats/hc-ui 0.9.0 → 0.11.0

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/README.md CHANGED
@@ -95,4 +95,4 @@ Fixed colors that stay the same in both themes are HeroClix tokens: `color-dial-
95
95
 
96
96
  `PhysicalCardView` is the printed card in HCUnits' format (375×525 px, absolute positions), scaled to its container's width: front and back side by side while each keeps about 86% of its size, otherwise one under the other, never above 1.2×. With `fitHeight` the front also fits the visible height of the scrolling area found by `fitHeightWithin`. Everything comes resolved: the backgrounds (`images.front`, `images.back`, `images.legacy`, `layout.rarityImage`, `layout.dialImage`: addresses the app serves; HCUI's are in its `public/card/` and do not ship here), the title (its type shrinks from 28px to fit), header lines, team abilities (buttons that open a tip, with the shield the caller passes in `teamAbilityIcon`), the collection number and set symbol, the token (`portrait` drawn by the caller with the class the card gives, `terrain` art or an `object` ring in its color), the bystander's token replica, keyphrases, the rules (already translated, with an optional `icon`; tapping a paragraph calls `onCopy(copyText)`), improved abilities, the printed dial as a node (`dial.node`, usually `CombatDialView` with `presentation="printed"`), the terrain dial, points (buttons that call `onSelectPoint`, an object's line or nothing) and the black back bar. Rules that do not fit the front move to the backs, two columns each, one block at a time (`nextPhysicalCardDistribution`); the split starts over when `contentKey` changes. Under a mouse the card tilts up to 3° toward the pointer, with parallax and a shine (nearly still over the dial); on a phone only the focus view moves, by the motion sensor (`requestPhysicalCardMotionAccess` asks iOS on the first touch). Holding the card with a finger for 450 ms calls `onRequestFocus`; the caller opens `PhysicalCardFocusView` (a modal `<dialog>` over a dimmed, blurred backdrop; Esc and a tap outside, once the opening finger is lifted, call `onClose`) with a second card with `focus`, which shows one side at a time and draws `focusActions({ nextSide, backs, flip })` under it (the library's `Button` `outline` there gets the surface fill). Tips are controlled as in the dial: hover with a mouse opens a passive tip described by the control, a press or Enter opens it as a dialog, leaving closes the passive one; the caller keeps `tip` and draws it in `tipSlot`. Paper, ink, shine and grain follow the theme (`color-card-*`, `filter-card-background`, `blend-card-*`, `opacity-card-grain*`); the printed black and white, the team-up band and the terrain dial are fixed (`color-card-print-*`, `color-card-team-up`, `color-card-terrain-panel*`). Letters are upright (`--hc-rond: 0` on the card, which its printed dial reads too). Motion only with a fine pointer or in the phone's focus view, and none under `prefers-reduced-motion`.
97
97
 
98
- `MapBoardView` draws a map already fetched (`map`: the hcmaps JSON, `HCMapJson`) with the hcmaps engine (`HCMap`) in a canvas named by `canvasLabel`, square-cornered and white in both themes (`color-map-board`), and shades the inspected square or draws the inspected edge (`selection`, from `inspectMap`; `color-map-selection*`). `onInspect(x, y, clientX, clientY)` gets the pointer in squares: mouse and pen while passing over, touch on press; `onLeave` when the mouse leaves. Other attributes (role, `tabIndex`, `onKeyDown`, `aria-*`) go to the container, so the caller adds the keyboard (HCUI: arrows move one square, Esc closes). `MapBoardPlaceholder` is the empty grid while it loads, a busy status whose `label` is visually hidden by the library. `MapTooltipView` is fixed next to `anchor` without leaving the screen (portal it out of a clipping container): the title, a wall's color, and for each square (one, or two on an edge) its coordinate, terrain and elevation swatches with the caller's names, the environment (a node, e.g. `MapEnvironmentView`, or `environmentNote`) and notes. `MapSwatch` is the board's square in a legend; `MapLegendView` lists them (the 3D model's legend) with children after; `MapEnvironmentView` shows indoor/outdoor with the caller's icons (black line icons, turned light in the dark theme by `filter-map-environment-icon`) and names. `renderMapThumbnail(json, height)` returns a PNG data URL. The engines are plain functions with no DOM beyond a canvas context: `inspectMap` (square, edge or wall at a point, in squares), `mapSquare`, `mapMaxLevel`, `mapElevationLevel`, `mapSquareCoordinate`; `buildMapScene` (the 3D model's heights, walls, ramps and ziplines), the orthographic camera (`makeMapProjection`, `pickMapScene`, `zoomMapCamera`, `clampMapPan`) and `drawMapScene` (Canvas 2D). The 3D model's component, its gestures and its icons stay in the app.
98
+ `MapBoardView` draws a map already fetched (`map`: the hcmaps JSON, `HCMapJson`) with the hcmaps engine (`HCMap`) in a canvas named by `canvasLabel`, square-cornered and white in both themes (`color-map-board`), and shades the inspected square or draws the inspected edge (`selection`, from `inspectMap`; `color-map-selection*`). `onInspect(x, y, clientX, clientY)` gets the pointer in squares: mouse and pen while passing over, touch on press; `onLeave` when the mouse leaves. Other attributes (role, `tabIndex`, `onKeyDown`, `aria-*`) go to the container, so the caller adds the keyboard (HCUI: arrows move one square, Esc closes). `MapBoardPlaceholder` is the empty grid while it loads, a busy status whose `label` is visually hidden by the library. `MapTooltipView` is fixed next to `anchor` without leaving the screen (portal it out of a clipping container): the title, a wall's color, and for each square (one, or two on an edge) its coordinate, terrain and elevation swatches with the caller's names, the environment (a node, e.g. `MapEnvironmentView`, or `environmentNote`) and notes. `MapSwatch` is the board's square in a legend; `MapLegendView` lists them (the 3D model's legend) with children after; `MapEnvironmentView` shows indoor/outdoor with the caller's icons (black line icons, turned light in the dark theme by `filter-map-environment-icon`) and names. `renderMapThumbnail(json, height)` returns a PNG data URL. The engines are plain functions with no DOM beyond a canvas context: `inspectMap` (square, edge or wall at a point, in squares), `mapSquare`, `mapMaxLevel`, `mapElevationLevel`, `mapSquareCoordinate`; `buildMapScene` (the 3D model's heights, walls, ramps and ziplines), the camera (`makeMapProjection`, `pickMapScene`, `zoomMapCamera`, `clampMapPan`) and `drawMapScene` (Canvas 2D). The camera is parallel by default; `makeMapProjection(scene, camera, viewport, { perspective: MAP_PERSPECTIVE_DISTANCE })` adds a light perspective (near squares larger, far ones smaller) in the same screen box, with `ground` still exact and `factor(x, y, z)` telling how much a point grew, e.g. to size a figure drawn over the model. `MapBoardView` draws again when the browser gives its canvas back blank (`contextrestored`). `drawMapScene(..., { decorate })` lets the app paint a side over its default face (`side(ctx, face)`, with the face's screen `quad`) and draw what stands on a square right after it in the painter's order (`cell(ctx, cell)`), or draw a ramp its own way (`ramp(ctx, ramp)` returning `true`), e.g. facades, props and stairs, without changing terrain or picking. The 3D model's component, its gestures and its icons stay in the app.
package/dist/index.d.ts CHANGED
@@ -1512,8 +1512,24 @@ export interface MapProjection {
1512
1512
  depth(x: number, y: number): number;
1513
1513
  /** From the screen to the map, on a plane at height `z`. */
1514
1514
  ground(px: number, py: number, z: number): [number, number];
1515
+ /**
1516
+ * How much a point at (x, y, z) is enlarged on screen compared with the parallel
1517
+ * projection: above 1 near the camera, below 1 far from it. Always 1 without
1518
+ * `perspective`. Optional so hand-made projections still type-check.
1519
+ */
1520
+ factor?(x: number, y: number, z: number): number;
1521
+ }
1522
+ export interface MapProjectionOptions {
1523
+ /**
1524
+ * A light perspective, as the camera's distance in half-diagonals of the scene
1525
+ * (`MAP_PERSPECTIVE_DISTANCE` = 4; larger is gentler). Off by default: a parallel
1526
+ * projection, where near and far squares are the same size.
1527
+ */
1528
+ perspective?: number;
1515
1529
  }
1516
- export declare function makeMapProjection(scene: MapScene, camera: MapCamera, viewport: MapViewport): MapProjection;
1530
+ /** The camera distance the Arena's table uses for `perspective`. */
1531
+ export declare const MAP_PERSPECTIVE_DISTANCE = 4;
1532
+ export declare function makeMapProjection(scene: MapScene, camera: MapCamera, viewport: MapViewport, { perspective }?: MapProjectionOptions): MapProjection;
1517
1533
  /** The map's center may go as far as any of its corners reaches the middle of the screen. */
1518
1534
  export declare function clampMapPan(scene: MapScene, camera: MapCamera, viewport: MapViewport): MapCamera;
1519
1535
  /** Zooms keeping the point at `anchor` (measured from the screen's center) still. */
@@ -1575,6 +1591,31 @@ export declare function mapFootprintOutline({ x, y, width, height, cells }: {
1575
1591
  edges: [Point, Point][];
1576
1592
  corners: Point[];
1577
1593
  };
1594
+ /** A square's visible side, as `drawMapScene` draws it: from the neighbor's top (`low`) to the square's. */
1595
+ export interface MapSceneFace {
1596
+ cell: MapSceneCell;
1597
+ /** The side's outward direction (one of `MAP_CELL_SIDES`). */
1598
+ dx: number;
1599
+ dy: number;
1600
+ low: number;
1601
+ top: number;
1602
+ /** Screen corners: low at the side's start, low at its end, top at its end, top at its start. */
1603
+ quad: [[number, number], [number, number], [number, number], [number, number]];
1604
+ /** How lit the side is, 0..1 (the default face is shaded by it). */
1605
+ light: number;
1606
+ }
1607
+ /**
1608
+ * What the app adds to the 3D model, in the painter's order of `drawMapScene`: a side
1609
+ * drawn over the default face (a facade, a wall's stones), and whatever stands on a
1610
+ * square, drawn right after that square (a car, a tree, a pillar), so nearer squares
1611
+ * and walls still cover it. Terrain, heights and picking do not change.
1612
+ */
1613
+ export interface MapSceneDecoration {
1614
+ side?(ctx: CanvasRenderingContext2D, face: MapSceneFace): void;
1615
+ cell?(ctx: CanvasRenderingContext2D, cell: MapSceneCell): void;
1616
+ /** Draws a ramp (e.g. as stairs) in place of the default wedge; return `true` when it did. */
1617
+ ramp?(ctx: CanvasRenderingContext2D, ramp: MapSceneRamp): boolean;
1618
+ }
1578
1619
  export interface MapSceneFootprint {
1579
1620
  x: number;
1580
1621
  y: number;
@@ -1599,8 +1640,9 @@ export interface MapSceneHighlight {
1599
1640
  /** The target of a piece being moved: floor and volume, cut to the board. */
1600
1641
  footprint?: MapSceneFootprint;
1601
1642
  }
1602
- export declare function drawMapScene(ctx: CanvasRenderingContext2D, scene: MapScene, view: MapProjection, texture: HTMLCanvasElement, highlight?: MapSceneHighlight | null, { tufts }?: {
1643
+ export declare function drawMapScene(ctx: CanvasRenderingContext2D, scene: MapScene, view: MapProjection, texture: HTMLCanvasElement, highlight?: MapSceneHighlight | null, { tufts, decorate }?: {
1603
1644
  tufts?: boolean;
1645
+ decorate?: MapSceneDecoration;
1604
1646
  }): void;
1605
1647
  /** The thumbnail's default height, in pixels. */
1606
1648
  export declare const MAP_THUMBNAIL_HEIGHT = 192;
package/dist/index.js CHANGED
@@ -2758,7 +2758,8 @@ var MAP_DEFAULT_CAMERA = { azimuth: -32, tilt: 52, zoom: 1, panX: 0, panY: 0 };
2758
2758
  var MAP_TILT_RANGE = [6, 90];
2759
2759
  var MAP_ZOOM_RANGE = [1, 6];
2760
2760
  var clampMapValue = (value, min, max) => Math.min(max, Math.max(min, value));
2761
- function makeMapProjection(scene, camera, viewport) {
2761
+ var MAP_PERSPECTIVE_DISTANCE = 4;
2762
+ function makeMapProjection(scene, camera, viewport, { perspective } = {}) {
2762
2763
  const rad = camera.azimuth * Math.PI / 180, tilt = camera.tilt * Math.PI / 180;
2763
2764
  const sin = Math.sin(rad), cos = Math.cos(rad), sinT = Math.sin(tilt), cosT = Math.cos(tilt);
2764
2765
  const cx2 = scene.width / 2, cy = scene.height / 2, midZ = (scene.maxTop + MAP_SCENE_BASE_Z) / 2;
@@ -2767,7 +2768,7 @@ function makeMapProjection(scene, camera, viewport) {
2767
2768
  const fit = Math.min(viewport.width / (halfWidth * 2.06), viewport.height / (halfHeight * 2.06));
2768
2769
  const scale = fit * camera.zoom;
2769
2770
  const ox = viewport.width / 2 + camera.panX, oy = viewport.height / 2 + camera.panY;
2770
- return {
2771
+ const parallel = {
2771
2772
  scale,
2772
2773
  sin,
2773
2774
  cos,
@@ -2781,6 +2782,44 @@ function makeMapProjection(scene, camera, viewport) {
2781
2782
  ground(px, py, z) {
2782
2783
  const rx = (px - ox) / scale, ry = ((py - oy) / scale + (z - midZ) * cosT) / sinT;
2783
2784
  return [cx2 + rx * cos + ry * sin, cy - rx * sin + ry * cos];
2785
+ },
2786
+ factor: () => 1
2787
+ };
2788
+ if (!perspective || !(perspective > 0)) return parallel;
2789
+ const [sx, sy] = parallel.point(cx2, cy, midZ);
2790
+ const D = perspective * Math.max(1, halfWidth, (scene.maxTop - MAP_SCENE_BASE_Z) / 2);
2791
+ const toward = (x, y, z) => parallel.depth(x, y) * cosT + (z - midZ) * sinT;
2792
+ const raw = (x, y, z) => D / Math.max(D * 0.05, D - toward(x, y, z));
2793
+ const corners = [[0, 0], [scene.width, 0], [scene.width, scene.height], [0, scene.height]];
2794
+ const extremes = corners.flatMap(([x, y]) => [[x, y, scene.maxTop], [x, y, MAP_SCENE_BASE_Z]]);
2795
+ const box = (points) => {
2796
+ const xs = points.map((p) => p[0]), ys = points.map((p) => p[1]);
2797
+ return [Math.max(...xs) - Math.min(...xs), Math.max(...ys) - Math.min(...ys)];
2798
+ };
2799
+ const [flatW, flatH] = box(extremes.map(([x, y, z]) => parallel.point(x, y, z)));
2800
+ const [deepW, deepH] = box(extremes.map(([x, y, z]) => {
2801
+ const [px, py] = parallel.point(x, y, z), f = raw(x, y, z);
2802
+ return [sx + (px - sx) * f, sy + (py - sy) * f];
2803
+ }));
2804
+ const norm = Math.max(flatW > 1e-9 ? deepW / flatW : 0, flatH > 1e-9 ? deepH / flatH : 0) || 1;
2805
+ const factor = (x, y, z) => raw(x, y, z) / norm;
2806
+ return {
2807
+ scale,
2808
+ sin,
2809
+ cos,
2810
+ factor,
2811
+ depth: parallel.depth,
2812
+ point(x, y, z) {
2813
+ const [px, py] = parallel.point(x, y, z), f = factor(x, y, z);
2814
+ return [sx + (px - sx) * f, sy + (py - sy) * f];
2815
+ },
2816
+ ground(px, py, z) {
2817
+ const zc = z - midZ;
2818
+ const v = (py - sy) / scale * norm;
2819
+ const denom = D * sinT + v * cosT;
2820
+ const along = Math.abs(denom) < 1e-9 ? 0 : (v * D - v * zc * sinT + zc * cosT * D) / denom;
2821
+ const f = D / Math.max(D * 0.05, D - along * cosT - zc * sinT) / norm;
2822
+ return parallel.ground(sx + (px - sx) / f, sy + (py - sy) / f, z);
2784
2823
  }
2785
2824
  };
2786
2825
  }
@@ -2834,11 +2873,9 @@ function pickMapScene(scene, view, px, py) {
2834
2873
  const p00 = view.point(cell.x, cell.y, cell.top), p10 = view.point(cell.x + 1, cell.y, cell.top);
2835
2874
  const p11 = view.point(cell.x + 1, cell.y + 1, cell.top), p01 = view.point(cell.x, cell.y + 1, cell.top);
2836
2875
  if (inside(px, py, [p00, p10, p11, p01])) {
2837
- const ax = p10[0] - p00[0], ay = p10[1] - p00[1], bx = p01[0] - p00[0], by = p01[1] - p00[1];
2838
- const det = ax * by - ay * bx;
2839
- if (!det) return { x: cell.x + 0.5, y: cell.y + 0.5 };
2840
- const u = ((px - p00[0]) * by - (py - p00[1]) * bx) / det, v = ((py - p00[1]) * ax - (px - p00[0]) * ay) / det;
2841
- return { x: cell.x + clampMapValue(u, 0, 1), y: cell.y + clampMapValue(v, 0, 1) };
2876
+ const [gx, gy] = view.ground(px, py, cell.top);
2877
+ if (!Number.isFinite(gx) || !Number.isFinite(gy)) return { x: cell.x + 0.5, y: cell.y + 0.5 };
2878
+ return { x: clampMapValue(gx, cell.x, cell.x + 1), y: clampMapValue(gy, cell.y, cell.y + 1) };
2842
2879
  }
2843
2880
  for (const side of MAP_CELL_SIDES) {
2844
2881
  if (side.dx * view.sin + side.dy * view.cos <= 0) continue;
@@ -2925,7 +2962,7 @@ function sideColor(cell) {
2925
2962
  if (cell.terrain === "window") return MAP_TERRAIN_EDGE_COLORS.window;
2926
2963
  return SIDE_COLORS[cell.environment] ?? SIDE_COLORS.unknown;
2927
2964
  }
2928
- function drawCellSides(ctx, scene, view, cell) {
2965
+ function drawCellSides(ctx, scene, view, cell, decorate) {
2929
2966
  const { point } = view;
2930
2967
  for (const side of MAP_CELL_SIDES) {
2931
2968
  if (side.dx * view.sin + side.dy * view.cos <= 0) continue;
@@ -2934,7 +2971,13 @@ function drawCellSides(ctx, scene, view, cell) {
2934
2971
  if (low >= cell.top) continue;
2935
2972
  const ax = cell.x + side.ax, ay = cell.y + side.ay, bx = cell.x + side.bx, by = cell.y + side.by;
2936
2973
  const light = faceLight(view, side.dx, side.dy);
2937
- face(ctx, [point(ax, ay, low), point(bx, by, low), point(bx, by, cell.top), point(ax, ay, cell.top)], shade(sideColor(cell), light));
2974
+ const quad = [point(ax, ay, low), point(bx, by, low), point(bx, by, cell.top), point(ax, ay, cell.top)];
2975
+ face(ctx, quad, shade(sideColor(cell), light));
2976
+ if (decorate?.side) {
2977
+ ctx.save();
2978
+ decorate.side(ctx, { cell, dx: side.dx, dy: side.dy, low, top: cell.top, quad, light });
2979
+ ctx.restore();
2980
+ }
2938
2981
  ctx.strokeStyle = "rgba(0,0,0,.26)";
2939
2982
  ctx.lineWidth = 1;
2940
2983
  for (let z = 0; z < cell.top - 0.01; z += MAP_SCENE_LEVEL_HEIGHT) {
@@ -3116,7 +3159,7 @@ function drawHighlight(ctx, scene, view, highlight) {
3116
3159
  ctx.lineWidth = 2;
3117
3160
  ctx.stroke();
3118
3161
  }
3119
- function drawMapScene(ctx, scene, view, texture, highlight, { tufts = true } = {}) {
3162
+ function drawMapScene(ctx, scene, view, texture, highlight, { tufts = true, decorate } = {}) {
3120
3163
  const pad = 0.35;
3121
3164
  polygon(ctx, [view.point(-pad, -pad, MAP_SCENE_BASE_Z - 0.16), view.point(scene.width + pad, -pad, MAP_SCENE_BASE_Z - 0.16), view.point(scene.width + pad, scene.height + pad, MAP_SCENE_BASE_Z - 0.16), view.point(-pad, scene.height + pad, MAP_SCENE_BASE_Z - 0.16)]);
3122
3165
  ctx.fillStyle = "rgba(0,0,0,.14)";
@@ -3139,11 +3182,24 @@ function drawMapScene(ctx, scene, view, texture, highlight, { tufts = true } = {
3139
3182
  items.push({
3140
3183
  depth: view.depth(cell.x + 0.5, cell.y + 0.5),
3141
3184
  draw() {
3142
- drawCellSides(ctx, scene, view, cell);
3185
+ drawCellSides(ctx, scene, view, cell, decorate);
3143
3186
  drawCellTop(ctx, scene, view, cell, texture, shadows);
3144
3187
  if (mixed && cell.environment === "indoor") drawIndoorCurb(ctx, scene, view, cell);
3145
- for (const ramp of rampsByCell.get(cell.y * scene.width + cell.x) ?? []) drawRamp(ctx, view, ramp);
3188
+ for (const ramp of rampsByCell.get(cell.y * scene.width + cell.x) ?? []) {
3189
+ let drawn = false;
3190
+ if (decorate?.ramp) {
3191
+ ctx.save();
3192
+ drawn = decorate.ramp(ctx, ramp);
3193
+ ctx.restore();
3194
+ }
3195
+ if (!drawn) drawRamp(ctx, view, ramp);
3196
+ }
3146
3197
  if (tufts && cell.terrain === "hindering") drawHindering(ctx, view, cell);
3198
+ if (decorate?.cell) {
3199
+ ctx.save();
3200
+ decorate.cell(ctx, cell);
3201
+ ctx.restore();
3202
+ }
3147
3203
  }
3148
3204
  });
3149
3205
  }
@@ -3189,14 +3245,19 @@ var MapBoardView = forwardRef(function MapBoardView2({ map, canvasLabel, selecti
3189
3245
  useEffect(() => {
3190
3246
  const canvas = canvasRef.current;
3191
3247
  if (!canvas) return;
3192
- const ctx = canvas.getContext("2d");
3193
- if (!ctx) return;
3194
- try {
3195
- ctx.clearRect(0, 0, canvas.width, canvas.height);
3196
- new HCMap(map).draw(ctx, { grid });
3197
- } catch {
3198
- failed.current?.();
3199
- }
3248
+ const draw = () => {
3249
+ const ctx = canvas.getContext("2d");
3250
+ if (!ctx) return;
3251
+ try {
3252
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
3253
+ new HCMap(map).draw(ctx, { grid });
3254
+ } catch {
3255
+ failed.current?.();
3256
+ }
3257
+ };
3258
+ draw();
3259
+ canvas.addEventListener("contextrestored", draw);
3260
+ return () => canvas.removeEventListener("contextrestored", draw);
3200
3261
  }, [map, grid]);
3201
3262
  function pointer(event) {
3202
3263
  const bounds = event.currentTarget.getBoundingClientRect();
@@ -3307,6 +3368,7 @@ export {
3307
3368
  MAP_GRID_SOFT_COLOR,
3308
3369
  MAP_GRID_TERRAIN_SHADE,
3309
3370
  MAP_INDOOR_EDGE_COLOR,
3371
+ MAP_PERSPECTIVE_DISTANCE,
3310
3372
  MAP_SCENE_BASE_Z,
3311
3373
  MAP_SCENE_LEVEL_HEIGHT,
3312
3374
  MAP_TERRAIN_EDGE_COLORS,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ntmats/hc-ui",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "description": "Shared HCUI/Arena React presentation primitives.",
6
6
  "license": "UNLICENSED",