@ntmats/hc-ui 0.9.0 → 0.10.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`). 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. */
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;
@@ -3189,14 +3226,19 @@ var MapBoardView = forwardRef(function MapBoardView2({ map, canvasLabel, selecti
3189
3226
  useEffect(() => {
3190
3227
  const canvas = canvasRef.current;
3191
3228
  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
- }
3229
+ const draw = () => {
3230
+ const ctx = canvas.getContext("2d");
3231
+ if (!ctx) return;
3232
+ try {
3233
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
3234
+ new HCMap(map).draw(ctx, { grid });
3235
+ } catch {
3236
+ failed.current?.();
3237
+ }
3238
+ };
3239
+ draw();
3240
+ canvas.addEventListener("contextrestored", draw);
3241
+ return () => canvas.removeEventListener("contextrestored", draw);
3200
3242
  }, [map, grid]);
3201
3243
  function pointer(event) {
3202
3244
  const bounds = event.currentTarget.getBoundingClientRect();
@@ -3307,6 +3349,7 @@ export {
3307
3349
  MAP_GRID_SOFT_COLOR,
3308
3350
  MAP_GRID_TERRAIN_SHADE,
3309
3351
  MAP_INDOOR_EDGE_COLOR,
3352
+ MAP_PERSPECTIVE_DISTANCE,
3310
3353
  MAP_SCENE_BASE_Z,
3311
3354
  MAP_SCENE_LEVEL_HEIGHT,
3312
3355
  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.10.0",
4
4
  "type": "module",
5
5
  "description": "Shared HCUI/Arena React presentation primitives.",
6
6
  "license": "UNLICENSED",