Add initial Nebula Core packages and docs
Import initial monorepo structure for Nebula Core: add packages (@nebula/core, core-glyphs, core-input, core-navigation, core-theme, core-ui, core-utils) with source, dist, tests and assets. Expand README with overview, quick start and API snippets, and add package-level documentation files. Add jsconfig.json for path mapping, package.json and lockfiles to bootstrap the repo. This commit sets up the project layout, docs, and local package links for further development.
This commit is contained in:
+23
@@ -0,0 +1,23 @@
|
||||
# @nebula/core-navigation
|
||||
|
||||
Focus management and spatial navigation primitives for controller-first UIs.
|
||||
|
||||
## Why it exists
|
||||
Directional navigation needs predictable, consistent logic for TVs and handhelds. This package provides small, testable functions that you can plug into any UI layer.
|
||||
|
||||
## Usage
|
||||
|
||||
```js
|
||||
import { pickBestCandidate } from "@nebula/core-navigation";
|
||||
|
||||
const current = { id: "settings", x: 100, y: 100, width: 180, height: 80 };
|
||||
const candidates = [
|
||||
{ id: "play", x: 100, y: 10, width: 180, height: 80 },
|
||||
{ id: "help", x: 320, y: 100, width: 180, height: 80 }
|
||||
];
|
||||
|
||||
const next = pickBestCandidate(current, candidates, "up");
|
||||
```
|
||||
|
||||
## SteamOS / Steam Deck notes
|
||||
Use these primitives to drive focus on elements that are at least 8–10mm tall on a TV. Combine with large hit targets and visible focus rings.
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@nebulaproject/core-navigation",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Focus management and spatial navigation primitives.",
|
||||
"type": "module",
|
||||
"exports": "./src/index.js",
|
||||
"main": "./src/index.js",
|
||||
"files": [
|
||||
"src"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @typedef {"up" | "down" | "left" | "right"} Direction
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} Rect
|
||||
* @property {string} id
|
||||
* @property {number} x
|
||||
* @property {number} y
|
||||
* @property {number} width
|
||||
* @property {number} height
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Rect} rect
|
||||
* @returns {{ x: number, y: number }}
|
||||
*/
|
||||
export function getRectCenter(rect) {
|
||||
return {
|
||||
x: rect.x + rect.width / 2,
|
||||
y: rect.y + rect.height / 2
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter candidates to those that are in the requested direction.
|
||||
* @param {Rect} current
|
||||
* @param {Rect[]} candidates
|
||||
* @param {Direction} direction
|
||||
* @returns {Rect[]}
|
||||
*/
|
||||
export function getDirectionalCandidates(current, candidates, direction) {
|
||||
const currentCenter = getRectCenter(current);
|
||||
return candidates.filter((candidate) => {
|
||||
const center = getRectCenter(candidate);
|
||||
switch (direction) {
|
||||
case "up":
|
||||
return center.y < currentCenter.y;
|
||||
case "down":
|
||||
return center.y > currentCenter.y;
|
||||
case "left":
|
||||
return center.x < currentCenter.x;
|
||||
case "right":
|
||||
return center.x > currentCenter.x;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the nearest candidate in a direction using a weighted distance.
|
||||
* @param {Rect} current
|
||||
* @param {Rect[]} candidates
|
||||
* @param {Direction} direction
|
||||
* @returns {Rect | null}
|
||||
*/
|
||||
export function pickBestCandidate(current, candidates, direction) {
|
||||
const currentCenter = getRectCenter(current);
|
||||
let best = null;
|
||||
let bestScore = Number.POSITIVE_INFINITY;
|
||||
|
||||
for (const candidate of getDirectionalCandidates(current, candidates, direction)) {
|
||||
const center = getRectCenter(candidate);
|
||||
const dx = center.x - currentCenter.x;
|
||||
const dy = center.y - currentCenter.y;
|
||||
const primary = direction === "left" || direction === "right" ? Math.abs(dx) : Math.abs(dy);
|
||||
const secondary = direction === "left" || direction === "right" ? Math.abs(dy) : Math.abs(dx);
|
||||
const score = primary * 1.25 + secondary;
|
||||
|
||||
if (score < bestScore) {
|
||||
bestScore = score;
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { pickBestCandidate } from "../src/index.js";
|
||||
|
||||
test("pickBestCandidate finds nearest in direction", () => {
|
||||
const current = { id: "center", x: 100, y: 100, width: 100, height: 100 };
|
||||
const candidates = [
|
||||
{ id: "up", x: 100, y: 0, width: 100, height: 100 },
|
||||
{ id: "right", x: 220, y: 100, width: 100, height: 100 }
|
||||
];
|
||||
|
||||
const next = pickBestCandidate(current, candidates, "up");
|
||||
assert.equal(next?.id, "up");
|
||||
});
|
||||
Reference in New Issue
Block a user