UNPKG

@zumer/orbit

Version:

Orbit builds radial interfaces with CSS geometry and a small JavaScript layout runtime

1,106 lines (1,100 loc) • 49.2 kB
/* * orbit * v.2.0.1 * Author Juan Martin Muda - Zumerlab * License MIT */ (() => { // src/js/orbit-layout.js var STATE = Symbol.for("zumer.orbit.layout.state.v1"); var REGISTRY = Symbol.for("zumer.orbit.layout.registry.v1"); var PROBE = Symbol.for("zumer.orbit.layout.probe.v1"); var VISUALS = "o-arc, o-progress"; var OBSERVE = { subtree: true, childList: true, characterData: true, attributes: true, attributeOldValue: true }; function orbitNumber(element) { var _a; for (const token of element.classList || []) { if (/^orbit-\d+$/.test(token)) return Number(token.slice(6)); } return ((_a = element.classList) == null ? void 0 : _a.contains("orbit")) ? null : void 0; } var WARNED = Symbol.for("zumer.orbit.warned.v1"); function warnOnce(element, key, message) { const seen = element[WARNED] || (element[WARNED] = /* @__PURE__ */ new Set()); if (seen.has(key)) return; seen.add(key); console.warn(`[orbit] ${message}`); } function composedClosest(element, selector) { var _a, _b; for (let current = element; current; current = current.parentElement || ((_a = current.getRootNode) == null ? void 0 : _a.call(current).host)) { if ((_b = current.matches) == null ? void 0 : _b.call(current, selector)) return current; } return null; } var KINDS = ".satellite, .vector, .side, o-arc, o-progress"; function describe(element) { const classes = [...element.classList].slice(0, 3).map((name) => "." + name).join(""); return `<${element.localName}${classes ? " " + classes : ""}>`; } function diagnose(ring) { const spot = ring.parentElement; const universe = composedClosest(spot.parentElement, ".bigbang, .satellite"); if (!universe) { warnOnce(spot, "universe", 'A .gravity-spot is not inside a .bigbang, so it measures the viewport. Wrap it: <div class="bigbang"><div class="gravity-spot">\u2026</div></div>.'); } else if (universe.getClientRects().length && (!universe.clientWidth || !universe.clientHeight)) { warnOnce(universe, "empty", `${describe(universe)} has no size (${universe.clientWidth}\xD7${universe.clientHeight}px), so its system is invisible. Give it a width, or place it in a parent with a width.`); } const visual = ring.querySelector(":scope > o-arc, :scope > o-progress"); if (visual) { const style = ring.ownerDocument.defaultView.getComputedStyle(ring); const x = style.getPropertyValue("--o-ellipse-x").trim(), y = style.getPropertyValue("--o-ellipse-y").trim(); if (x && Number(x) !== 1 || y && Number(y) !== 1) { warnOnce(ring, "ellipse-visual", `${describe(visual)} is on ${describe(ring)}, which is stretched into an ellipse (--o-ellipse-x: ${x || 1}, --o-ellipse-y: ${y || 1}). Arcs and progress are drawn on circles only, so it is hidden. Put it on a round ring.`); } } if (!composedClosest(ring, ".dev-orbit")) return; for (const child of ring.children) { if (orbitNumber(child) !== void 0) { warnOnce(child, "nested-ring", `${describe(child)} is inside another ring. Rings must be direct children of .gravity-spot; to nest a system, put a .gravity-spot directly inside a .satellite.`); } else if (!child.matches(KINDS)) { warnOnce(child, "ring-child", `${describe(child)} is not placed by its ring. A ring holds only .satellite, .vector, .side, <o-arc> and <o-progress>; wrap content as <div class="satellite"><div class="capsule">\u2026</div></div>.`); } else if (child.classList.contains("satellite")) { for (const inner of child.children) { if (!inner.matches(".capsule, .gravity-spot, .bigbang")) { warnOnce(inner, "satellite-child", `${describe(inner)} sits directly in a .satellite. Put content in a .capsule so it stays upright and centered.`); } } } } } function setLayoutProperty(element, name, value) { const text = String(value); if (element.style.getPropertyValue(name) !== text) element.style.setProperty(name, text); } function cssNumber(element, value, fallback = 0, type = "number") { const raw = String(value || "").trim(); if (!raw) return fallback; if (type === "number" && /^[+-]?(?:\d+\.?\d*|\.\d+)$/.test(raw)) return Number(raw); const angle = raw.match(/^([+-]?(?:\d+\.?\d*|\.\d+))(deg|rad|grad|turn)?$/); if (type === "angle" && angle) { return Number(angle[1]) * ({ deg: 1, rad: 180 / Math.PI, grad: 0.9, turn: 360 }[angle[2]] || 1); } const doc = element.ownerDocument; if (!(doc == null ? void 0 : doc.documentElement)) return fallback; let probe = doc[PROBE]; if (!probe) { probe = doc.createElement("span"); probe.setAttribute("data-orbit-measure", ""); probe.setAttribute("aria-hidden", "true"); probe.style.cssText = "all:initial!important;position:fixed!important;visibility:hidden!important;pointer-events:none!important;width:0!important;height:0!important;overflow:hidden!important;"; doc.documentElement.appendChild(probe); Object.defineProperty(doc, PROBE, { value: probe, configurable: true }); } const property = type === "angle" ? "rotate" : "scale"; probe.style.removeProperty(property); probe.style.setProperty(property, raw, "important"); if (!probe.style.getPropertyValue(property)) return fallback; const computed = doc.defaultView.getComputedStyle(probe).getPropertyValue(property); const resolved = parseFloat(computed); return Number.isFinite(resolved) ? resolved : fallback; } function group(element) { if (element.localName === "o-arc") return "arc"; for (const name of ["satellite", "vector", "side"]) if (element.classList.contains(name)) return name; return null; } function layoutRing(ring) { const counts = { arc: 0, satellite: 0, vector: 0, side: 0 }; for (const child of ring.children) { const kind = group(child); if (kind) setLayoutProperty(child, "--o-layout-index", counts[kind]++ - (kind === "side" ? 1 : 0)); else if (child.localName === "o-progress") setLayoutProperty(child, "--o-layout-index", 0); } const count = Math.max(counts.arc, counts.satellite, counts.vector, 1); const points = counts.satellite || counts.vector; const divisor = counts.side ? String(counts.side) : points ? `max(1, ${count} - var(--o-fit-range, 0))` : String(count); setLayoutProperty(ring, "--o-layout-angle", `calc(var(--o-range, 360deg) / ${divisor})`); } function isScope(root) { return (root == null ? void 0 : root.nodeType) === 9 || (root == null ? void 0 : root.nodeType) === 11 && !!root.host; } function scopeFor(element) { var _a; if (isScope(element)) return element; const root = (_a = element == null ? void 0 : element.getRootNode) == null ? void 0 : _a.call(element); return isScope(root) ? root : null; } function composedContains(ancestor, node) { var _a, _b; for (let current = node; current; current = (_a = current.getRootNode) == null ? void 0 : _a.call(current).host) { if (ancestor === current || ((_b = ancestor.contains) == null ? void 0 : _b.call(ancestor, current))) return true; } return false; } function registryFor(doc) { if (!doc[REGISTRY]) Object.defineProperty(doc, REGISTRY, { value: { states: /* @__PURE__ */ new Set() }, configurable: true }); return doc[REGISTRY]; } function cancel(state) { if (state.frame < 0) state.view.clearTimeout(-state.frame); else if (state.frame) state.view.cancelAnimationFrame(state.frame); state.frame = 0; } function settle(state) { for (const resolve of state.waiters.splice(0)) resolve(); } function dispose(state) { if (state.disposed) return; state.disposed = true; cancel(state); settle(state); state.observer.disconnect(); state.resize.disconnect(); for (const remove of state.listeners) remove(); state.rings.clear(); state.visuals.clear(); state.dirty.clear(); state.registry.states.delete(state); if (state.scope[STATE] === state) delete state.scope[STATE]; } function pruneDisconnected(registry) { for (const state of registry.states) { if (state.scope.host && !state.scope.host.isConnected) dispose(state); } } function invalidateDescendants(state, target = state.scope) { for (const other of state.registry.states) { if (other !== state && other.scope.host && composedContains(target, other.scope.host)) { other.all = true; other.queue(); } } } function hasLayoutStructure(node) { if (node.nodeType !== 1 || node.hasAttribute("data-orbit-measure")) return false; if (orbitNumber(node) !== void 0 || node.matches(".gravity-spot, o-arc, o-progress")) return true; return !!node.querySelector(".gravity-spot, .orbit, [data-orbit-ring], o-arc, o-progress"); } function hasStylesheet(node) { return node.nodeType === 1 && (node.matches('style, link[rel~="stylesheet"]') || !!node.querySelector('style, link[rel~="stylesheet"]')); } function needsDiscovery(record, state) { if (record.type === "childList") { return [...record.addedNodes, ...record.removedNodes].some(hasLayoutStructure); } if (record.attributeName !== "class") return false; const element = record.target; return state.rings.has(element) || element.classList.contains("gravity-spot") || orbitNumber(element) !== void 0 || (record.oldValue || "").split(/\s+/).some((token) => token === "gravity-spot" || token === "orbit" || /^orbit-\d+$/.test(token)); } function createState(scope) { const doc = scope.nodeType === 9 ? scope : scope.ownerDocument; const view = doc.defaultView; const registry = registryFor(doc); const state = { scope, doc, view, registry, rings: /* @__PURE__ */ new Set(), visuals: /* @__PURE__ */ new Set(), dirty: /* @__PURE__ */ new Set(), scan: true, all: true, frame: 0, disposed: false, listeners: [], waiters: [] }; const listen = (target, event, listener, options) => { target == null ? void 0 : target.addEventListener(event, listener, options); state.listeners.push(() => target == null ? void 0 : target.removeEventListener(event, listener, options)); }; const queue = () => { if (state.disposed || state.frame) return; const run = () => { state.frame = 0; state.flush(); }; state.frame = doc.visibilityState === "hidden" ? -view.setTimeout(run, 0) : view.requestAnimationFrame(run); }; state.queue = queue; state.flush = () => flush(state); state.invalidate = (target = scope, includeDescendants = true) => { var _a, _b; if (includeDescendants && (target === scope || target === doc.documentElement || target === doc.head)) state.all = true; else { for (const ring of state.rings) if (ring === target || ring.contains(target) || includeDescendants && ((_a = target.contains) == null ? void 0 : _a.call(target, ring))) state.dirty.add(ring); for (const visual of state.visuals) if (visual === target || visual.contains(target) || includeDescendants && ((_b = target.contains) == null ? void 0 : _b.call(target, visual))) state.dirty.add(visual); } if (state.all || state.dirty.size || state.scan) queue(); }; state.observer = new view.MutationObserver((records) => { var _a, _b; if (records.some((record) => record.type === "childList")) pruneDisconnected(registry); if (state.disposed) return; for (const record of records) { const target = record.target.nodeType === 3 ? record.target.parentElement : record.target; if (!target || ((_a = target.hasAttribute) == null ? void 0 : _a.call(target, "data-orbit-measure"))) continue; const structural = needsDiscovery(record, state); if (structural) state.scan = true; const stylesheetChange = record.type === "childList" && [...record.addedNodes, ...record.removedNodes].some(hasStylesheet); if (stylesheetChange || ((_b = target.closest) == null ? void 0 : _b.call(target, "head, style")) || target.localName === "link") { state.all = true; invalidateDescendants(state); } else if (record.type === "attributes") invalidateDescendants(state, target); state.invalidate(target, record.type === "attributes" || structural); } }); state.observer.observe(scope, OBSERVE); state.resize = new view.ResizeObserver((entries) => { for (const entry of entries) state.dirty.add(entry.target); queue(); }); const invalidateAll = () => { state.all = true; invalidateDescendants(state); queue(); }; listen(doc, "visibilitychange", () => { if (doc.visibilityState === "hidden" && state.frame > 0) { cancel(state); queue(); } }); if (scope === doc) { listen(view, "resize", invalidateAll); listen(doc.fonts, "loadingdone", invalidateAll); } listen(scope, "load", (event) => { if (event.target.localName === "link") invalidateAll(); }, true); for (const event of ["pointerover", "pointerout", "focusin", "focusout"]) { listen(scope, event, (e) => { state.invalidate(e.target); invalidateDescendants(state, e.target); }, true); } Object.defineProperty(scope, STATE, { value: state, configurable: true }); registry.states.add(state); queue(); return state; } function ensureState(scope) { const doc = scope.nodeType === 9 ? scope : scope.ownerDocument; if (!(doc == null ? void 0 : doc.defaultView) || !doc.documentElement || scope.host && !scope.host.isConnected) return null; if (scope[STATE] && scope[STATE].doc !== doc) dispose(scope[STATE]); if (scope.host) { const parent = scopeFor(scope.host); if (parent) ensureState(parent); } return scope[STATE] || createState(scope); } function discover(state) { const nextRings = /* @__PURE__ */ new Set(); const automaticCounts = /* @__PURE__ */ new Map(); for (const ring of state.scope.querySelectorAll(".gravity-spot > *")) { const number = orbitNumber(ring); if (number === void 0) continue; let automatic = automaticCounts.get(ring.parentElement) || 0; if (ring.classList.contains("orbit")) automaticCounts.set(ring.parentElement, ++automatic); const resolved = number === null ? automatic : number; ring.setAttribute("data-orbit-ring", ""); setLayoutProperty(ring, "--o-layout-number", resolved === 0 ? 1e-5 : resolved); nextRings.add(ring); state.dirty.add(ring); if (!state.rings.has(ring)) state.resize.observe(ring); } for (const old of state.rings) if (!nextRings.has(old)) { state.resize.unobserve(old); old.removeAttribute("data-orbit-ring"); for (const name of ["--o-layout-number", "--o-layout-angle"]) old.style.removeProperty(name); } state.rings = nextRings; const nextVisuals = new Set(state.scope.querySelectorAll(VISUALS)); for (const old of state.visuals) if (!nextVisuals.has(old)) state.resize.unobserve(old); for (const visual of nextVisuals) if (!state.visuals.has(visual)) { state.resize.observe(visual); state.dirty.add(visual); } state.visuals = nextVisuals; state.scan = false; } function flush(state) { if (state.disposed) return; if (state.scope.host && !state.scope.host.isConnected) { dispose(state); return; } state.observer.disconnect(); const drawn = []; try { if (state.scan) discover(state); const dirty = state.all ? /* @__PURE__ */ new Set([...state.rings, ...state.visuals]) : state.dirty; const visuals = /* @__PURE__ */ new Set(); for (const target of dirty) { if (!target.isConnected) continue; if (state.rings.has(target)) { layoutRing(target); diagnose(target); for (const visual of target.querySelectorAll(VISUALS)) visuals.add(visual); } else if (state.visuals.has(target)) visuals.add(target); } for (const visual of state.visuals) if (visuals.has(visual) && visual.update) { visual.update(); drawn.push(visual); } state.dirty = /* @__PURE__ */ new Set(); state.all = false; } finally { if (!state.disposed) state.observer.observe(state.scope, OBSERVE); } for (const visual of drawn) visual.dispatchEvent(new state.view.Event("o-rendered", { bubbles: true, composed: true })); if (!state.frame) settle(state); } function requestLayout(element = globalThis.document) { var _a; const scope = scopeFor(element); if (!scope) return; const state = ensureState(scope); if (!state) return; if (((_a = element == null ? void 0 : element.matches) == null ? void 0 : _a.call(element, VISUALS)) && !state.visuals.has(element)) state.scan = true; state.invalidate(element || scope); } function refreshLayout(root = globalThis.document) { const scope = scopeFor(root); if (!scope) return; const state = ensureState(scope); if (!state) return; pruneDisconnected(state.registry); for (const current of state.registry.states) { if (current !== state && (!current.scope.host || !composedContains(root, current.scope.host))) continue; current.scan = true; current.all = true; cancel(current); current.flush(); } } function layoutReady(root = globalThis.document) { const scope = scopeFor(root); const state = scope && ensureState(scope); if (!state) return Promise.resolve(); pruneDisconnected(state.registry); const pending = [...state.registry.states].filter((current) => current === state || current.scope.host && composedContains(root, current.scope.host)); return Promise.all(pending.map((current) => new Promise((resolve) => { current.waiters.push(resolve); current.queue(); }))).then(() => { }); } // src/js/orbit-base.js var OrbitBase = class extends (globalThis.HTMLElement || class { }) { constructor() { super(); } connectedCallback() { for (const name of ["value", "max"]) { if (Object.prototype.hasOwnProperty.call(this, name)) { const pending = this[name]; delete this[name]; this[name] = pending; } } requestLayout(this); } /** value and max mirror their attributes, so property bindings redraw too. */ get value() { const raw = this.getAttribute("value"); return raw === null || raw.trim() === "" ? null : Number(raw); } set value(next) { if (next === null || next === void 0 || next === "") this.removeAttribute("value"); else this.setAttribute("value", String(next)); } get max() { const raw = this.getAttribute("max"); return raw === null ? 100 : Number(raw); } set max(next) { if (next === null || next === void 0 || next === "") this.removeAttribute("max"); else this.setAttribute("max", String(next)); } disconnectedCallback() { if (this._textFrame) this.ownerDocument.defaultView.cancelAnimationFrame(this._textFrame); this._textFrame = 0; } readNumber(style, name, fallback = 0) { return cssNumber(this, style.getPropertyValue(name), fallback); } readAngle(style, name, fallback = 0) { return cssNumber(this, style.getPropertyValue(name), fallback, "angle"); } /** The value attribute as a number. Invalid values draw nothing, and say why. */ readValue() { const raw = this.getAttribute("value"); if (raw === null || raw.trim() === "") return 0; const value = Number(raw); if (!Number.isFinite(value)) { warnOnce(this, "value:" + raw, `<${this.localName} value="${raw}"> is not a number, so nothing is drawn.`); return 0; } return value; } /** The max attribute (default 100). It must be a positive number. */ readMax() { const raw = this.getAttribute("max"); if (raw === null) return 100; const max = Number(raw); if (!(max > 0)) { warnOnce(this, "max:" + raw, `<${this.localName} max="${raw}"> must be a positive number, so nothing is drawn.`); return 0; } return max; } getCommonAttributes(element) { var _a; const style = element.ownerDocument.defaultView.getComputedStyle(element); const measuredRadius = parseFloat(style.getPropertyValue("r")); const orbitRadius = Math.max(0, Number.isFinite(measuredRadius) ? measuredRadius : (((_a = element.parentElement) == null ? void 0 : _a.clientWidth) || 0) / 2); const orbitNumber2 = Math.max(1e-5, this.readNumber(style, "--o-orbit-number", 1)); const size = Math.max(0, this.readNumber(style, "--o-size-ratio", 1)); const strokeWidth = Math.max(0, this.readNumber(style, "--o-stroke-width", 1)); const shape = element.getAttribute("shape") || "none"; const arcHeight = Math.max(0, orbitRadius / orbitNumber2 * size - strokeWidth + 0.3); const arcHeightPercentage = orbitRadius > 0 ? Math.min(49.999, arcHeight * 25 / orbitRadius) : 0; let innerOuter = 0; if (element.classList.contains("outer-orbit")) { innerOuter = arcHeightPercentage; } else if (element.classList.contains("quarter-outer-orbit")) { innerOuter = arcHeightPercentage * -0.5; } else if (element.classList.contains("inner-orbit")) { innerOuter = arcHeightPercentage * -1; } else if (element.classList.contains("quarter-inner-orbit")) { innerOuter = arcHeightPercentage * 0.5; } const realRadius = 50 + innerOuter; return { orbitRadius, arcHeight, realRadius, arcAngle: 0, // set by each component shape, arcHeightPercentage, orbitNumber: orbitNumber2, size, strokeWidth, style }; } getProgressAngle(maxAngle, value, maxValue = 100) { if (!Number.isFinite(value) || !Number.isFinite(maxValue) || maxValue <= 0) return 0; return Math.min(1, Math.max(0, value / maxValue)) * Math.max(0, Math.min(360, maxAngle)); } arcPoint(radius, angle, radiusAdjustment = 0, angleOffsetDegrees = 0) { const adjustedRadius = radius + radiusAdjustment; const adjustedAngle = angle + angleOffsetDegrees * Math.PI / 180; return { x: 50 + adjustedRadius * Math.cos(adjustedAngle), y: 50 + adjustedRadius * Math.sin(adjustedAngle) }; } calculateCommonArcParameters(arcAngle, radius, arcHeightPercentage, orbitNumber2, shape, strokeWidth, arcHeight, gap = 0) { const offset = Math.PI / 2; const fangle = Math.max(0, Math.min(360, arcAngle)) * Math.PI / 180; const bigRadius = radius + arcHeightPercentage; const smallRadius = Math.max(1e-3, radius - arcHeightPercentage); const bigGap = Math.min(fangle * 0.49, (gap + strokeWidth * 1.25) / orbitNumber2 / bigRadius); const smallGap = Math.min(fangle * 0.49, (gap + strokeWidth * 1.25) / orbitNumber2 / smallRadius); const upperAngleStart = bigGap - offset; const upperAngleEnd = fangle - bigGap - offset; const innerAngleStart = smallGap - offset; const innerAngleEnd = fangle - smallGap - offset; const upperArcStart = this.arcPoint(bigRadius, upperAngleStart); const upperArcEnd = this.arcPoint(bigRadius, upperAngleEnd); const innerArcStart = this.arcPoint(smallRadius, innerAngleStart); const innerArcEnd = this.arcPoint(smallRadius, innerAngleEnd); return { upperArcStart, upperArcEnd, innerArcStart, innerArcEnd, bigRadius, smallRadius, radius, upperAngleStart, upperAngleEnd, innerAngleStart, innerAngleEnd }; } generatePathData(shape, params) { switch (shape) { case "rounded": return this.generateRoundedPath(params); case "circle": case "circle-a": case "bullet": return this.generateCirclePath(params, shape); case "circle-b": return this.generateCircleBPath(params); case "arrow": return this.generateArrowPath(params); case "backslash": case "slash": return this.generateSlashPath(params, shape); case "zigzag": return this.generateZigzagPath(params); default: return this.generateDefaultPath(params); } } /** "x,y" of a point at radius and angle (radians), in viewBox units. */ pt(radius, angle) { const point = this.arcPoint(radius, angle); return `${point.x},${point.y}`; } /** * SVG arc commands from the current point (at `from`) to `to`, along one * circle. Every arc in Orbit goes through here. Splitting into pieces of at * most 90° means no large-arc flag to get wrong and no arc whose endpoints * coincide, so a full 360° circle is just another arc. */ arcTo(radius, from, to) { const steps = Math.max(1, Math.ceil(Math.abs(to - from) / (Math.PI / 2) - 1e-9)); const sweep = to >= from ? 1 : 0; let d = ""; for (let i = 1; i <= steps; i++) { d += ` A ${radius},${radius} 0 0 ${sweep} ${this.pt(radius, from + (to - from) * i / steps)}`; } return d; } /** An open arc along one radius: strokes, curved text and touch areas. */ linePath(radius, from, to) { return `M ${this.pt(radius, from)}${this.arcTo(radius, from, to)}`; } /** * Shape endings are sized from the band itself (h = half its thickness, in * viewBox units), so they look the same on every ring and at every size. * Angles are in radians, as in calculateCommonArcParameters(). */ ending(params, factor) { const { bigRadius, smallRadius } = params; const h = (bigRadius - smallRadius) / 2; const middle = (bigRadius + smallRadius) / 2; const span = Math.min(params.upperAngleEnd - params.upperAngleStart, params.innerAngleEnd - params.innerAngleStart); const length = Math.max(0, Math.min(h * factor, span * smallRadius * 0.49)); return { h, middle, length, angle: length / middle }; } generateDefaultPath(params) { const { bigRadius: R, smallRadius: r, upperAngleStart: uS, upperAngleEnd: uE, innerAngleStart: iS, innerAngleEnd: iE } = params; return `M ${this.pt(R, uS)}${this.arcTo(R, uS, uE)} L ${this.pt(r, iE)}${this.arcTo(r, iE, iS)} Z`; } generateRoundedPath(params) { const { bigRadius: R, smallRadius: r, upperAngleStart: uS, upperAngleEnd: uE, innerAngleStart: iS, innerAngleEnd: iE } = params; const { length: c } = this.ending(params, 0.5); const corner = (radius, angle) => ` A ${c},${c} 0 0 1 ${this.pt(radius, angle)}`; let d = `M ${this.pt(R, uS + c / R)}${this.arcTo(R, uS + c / R, uE - c / R)}`; d += `${corner(R - c, uE)} L ${this.pt(r + c, iE)}${corner(r, iE - c / r)}`; d += this.arcTo(r, iE - c / r, iS + c / r); d += `${corner(r + c, iS)} L ${this.pt(R - c, uS)}${corner(R, uS + c / R)} Z`; return d; } generateCirclePath(params, shape) { const { bigRadius: R, smallRadius: r, upperAngleStart: uS, upperAngleEnd: uE, innerAngleStart: iS, innerAngleEnd: iE } = params; const startSweep = shape === "bullet" ? 0 : 1; let d = `M ${this.pt(R, uS)}${this.arcTo(R, uS, uE)} A 1,1 0 0 1 ${this.pt(r, iE)}`; d += `${this.arcTo(r, iE, iS)} A 1,1 0 0 ${startSweep} ${this.pt(R, uS)} Z`; return d; } generateCircleBPath(params) { const { bigRadius: R, smallRadius: r } = params; const { h, angle } = this.ending(params, 1); const a0 = params.upperAngleStart + angle; const a1 = params.upperAngleEnd - angle; let d = `M ${this.pt(R, a0)}${this.arcTo(R, a0, a1)} A ${h},${h} 0 0 1 ${this.pt(r, a1)}`; d += `${this.arcTo(r, a1, a0)} A ${h},${h} 0 0 1 ${this.pt(R, a0)} Z`; return d; } generateArrowPath(params) { const { bigRadius: R, smallRadius: r } = params; const { middle, angle } = this.ending(params, 1.6); const a0 = params.upperAngleStart; const a1 = params.upperAngleEnd; let d = `M ${this.pt(R, a0)}${this.arcTo(R, a0, a1 - angle)}`; d += ` L ${this.pt(middle, a1)} L ${this.pt(r, a1 - angle)}`; d += `${this.arcTo(r, a1 - angle, a0)} L ${this.pt(middle, a0 + angle)} Z`; return d; } generateSlashPath(params, shape) { const { bigRadius: R, smallRadius: r } = params; const { angle } = this.ending(params, 1); const a0 = params.upperAngleStart; const a1 = params.upperAngleEnd; const [outer0, outer1, inner0, inner1] = shape === "backslash" ? [a0, a1 - angle, a0 + angle, a1] : [a0 + angle, a1, a0, a1 - angle]; return `M ${this.pt(R, outer0)}${this.arcTo(R, outer0, outer1)} L ${this.pt(r, inner1)}${this.arcTo(r, inner1, inner0)} Z`; } generateZigzagPath(params) { const { bigRadius: R, smallRadius: r } = params; const { middle, angle } = this.ending(params, 1.6); const a0 = params.upperAngleStart; const a1 = params.upperAngleEnd; let d = `M ${this.pt(R, a0 + angle)}${this.arcTo(R, a0 + angle, a1)}`; d += ` L ${this.pt(middle, a1 - angle)} L ${this.pt(r, a1)}`; d += `${this.arcTo(r, a1, a0 + angle)} L ${this.pt(middle, a0)} Z`; return d; } /** * The touch area of an interactive arc or progress: an invisible stroke * along the middle of the band, from `from` to `to` degrees (0 = start of * the element). It is as wide as the band plus `hit-width` px on each side, * and catches the pointer whatever the fill, so transparent bands work. */ updateHitArea(hit, attrs, from, to) { const extra = Math.max(0, Number(this.getAttribute("hit-width")) || 0); this.fitCanvas(attrs, this.hasAttribute("interactive") ? extra : 0); if (!this.hasAttribute("interactive") || !(to > from) || !(attrs.orbitRadius > 0)) { hit.removeAttribute("d"); return; } const radians = (degrees) => (degrees - 90) * Math.PI / 180; hit.setAttribute("d", this.linePath(attrs.realRadius, radians(from), radians(Math.min(to, from + 360)))); hit.style.strokeWidth = `${attrs.arcHeight + attrs.strokeWidth + 2 * extra}px`; } /** * Grow the SVG canvas past the ring's box so it covers the whole band, its * stroke and touch area. Some engines (WebKit) neither paint reliably nor * hit-test SVG content that overflows its box. */ fitCanvas(attrs, extra) { const svg = this.shadowRoot.querySelector("svg"); const scale = attrs.orbitRadius / 50; if (!svg || !(scale > 0)) return; const outer = (attrs.realRadius + attrs.arcHeightPercentage) * scale + attrs.strokeWidth + extra; const pad = Math.max(0, Math.ceil(outer - attrs.orbitRadius)); const units = pad / scale; svg.setAttribute("viewBox", `${-units} ${-units} ${100 + 2 * units} ${100 + 2 * units}`); svg.style.inset = `${-pad}px`; } }; // src/js/orbit-progress.js var OrbitProgress = class extends OrbitBase { constructor() { super(); this.attachShadow({ mode: "open" }); this.shadowRoot.innerHTML = ` <style> :host { --o-fill: var(--o-gray-light); --o-stroke: var(--o-fill); --o-stroke-width: 1; --o-back-fill: transparent; --o-back-stroke: none; --o-back-stroke-width: 1; } svg { position: absolute; inset: 0; width: auto; height: auto; overflow: visible; pointer-events: none; } /* Display element by default: never steals clicks (see orbit-arc). Interaction is opt-in via the "interactive" attribute. */ svg * { pointer-events: none; } :host([interactive]) svg * { pointer-events: visiblePainted; } /* The touch area covers the whole track, whatever its colors. */ .hit { fill: none; stroke: transparent; } :host([interactive]) .hit { pointer-events: stroke; } :host([interactive]) { cursor: pointer; } .progress-bar { fill: var(--o-fill); stroke: var(--o-stroke); stroke-width: var(--o-stroke-width); transition: fill 0.25s, stroke 0.25s; stroke-linejoin: round; } .progress-bg { fill: var(--o-back-fill); stroke: var(--o-back-stroke); stroke-width: var(--o-back-stroke-width); } /* variant="stroke": the thin, legible gauge. The default band is a FILLED donut wedge, so everyone building a thin gauge tripped on --o-fill/--o-back-fill. With this variant the paths are open arcs and the data color lives where you expect it: --o-stroke for the bar, --o-back-stroke for the track. */ /* The stroke variant draws a 2px bar over a gray 1px track unless you set your own values on the element. */ :host([variant="stroke"]) { --o-stroke-width: 2; --o-back-stroke: var(--o-gray-light); } :host([variant="stroke"]) .progress-bar { fill: none; stroke: var(--o-stroke); stroke-width: var(--o-stroke-width, 2); stroke-linecap: round; } :host([variant="stroke"]) .progress-bg { fill: none; stroke: var(--o-back-stroke, var(--o-gray-light)); stroke-width: var(--o-back-stroke-width, 1); stroke-linecap: round; } </style> <svg viewBox="0 0 100 100"> <path class="hit" vector-effect="non-scaling-stroke"></path> <path class="progress-bg" shape-rendering="geometricPrecision" vector-effect="non-scaling-stroke"></path> <path class="progress-bar" shape-rendering="geometricPrecision" vector-effect="non-scaling-stroke"></path> </svg> `; } update() { const attrs = this.getAttributes(); const isStroke = this.getAttribute("variant") === "stroke"; const dBg = isStroke ? this.calculateStrokeArc(attrs, true) : this.calculateArcParameters(attrs, true); const dBar = isStroke ? this.calculateStrokeArc(attrs, false) : this.calculateArcParameters(attrs, false); this.shadowRoot.querySelector(".progress-bg").setAttribute("d", dBg); this.shadowRoot.querySelector(".progress-bar").setAttribute("d", dBar); this.updateHitArea(this.shadowRoot.querySelector(".hit"), attrs, 0, attrs.range); } /** * variant="stroke": open arc along the orbit radius (no closed band), * stroked by CSS. Same progress math as the band variant. */ calculateStrokeArc(attrs, full) { const { realRadius } = attrs; const arcAngle = Math.max(0, Math.min(this.getProgressAngle(attrs, full), 360)); if (!(arcAngle > 0) || !(attrs.orbitRadius > 0)) return ""; const start = -Math.PI / 2; return this.linePath(realRadius, start, start + arcAngle * Math.PI / 180); } getAttributes() { const common = super.getCommonAttributes(this); const range = Math.max(0, Math.min(360, this.readAngle(common.style, "--o-range", 360))); const rawValue = common.style.getPropertyValue("--o-progress").trim(); const progress = rawValue ? this.readNumber(common.style, "--o-progress", 0) : this.readValue(); const maxValue = this.readMax(); return { ...common, range, progress, maxValue }; } getProgressAngle(attrs, full) { const { range, progress, maxValue } = attrs; return full ? range : super.getProgressAngle(range, progress, maxValue); } calculateArcParameters(attrs, full) { const { shape, realRadius, arcHeightPercentage, orbitNumber: orbitNumber2, strokeWidth, arcHeight } = attrs; const arcAngle = this.getProgressAngle(attrs, full); if (!(arcAngle > 0) || !(attrs.orbitRadius > 0)) return ""; const params = super.calculateCommonArcParameters( arcAngle, realRadius, arcHeightPercentage, orbitNumber2, shape, strokeWidth, arcHeight ); return super.generatePathData(shape, params); } }; // src/js/orbit-arc.js var template = typeof document === "undefined" ? null : document.createElement("template"); if (template) template.innerHTML = ` <style> :host { --o-fill: var(--o-gray-light); --o-stroke: var(--o-fill); --o-stroke-width: 1; --o-color: currentcolor; } svg { position: absolute; inset: 0; width: auto; height: auto; overflow: visible; pointer-events: none; } /* Arcs are display elements by default: they must NOT steal clicks from satellites/controls nearby (a painted arc used to capture clicks even at opacity 0, and pointer-events:none on the host could not pierce the shadow). Interaction is opt-in via the "interactive" attribute. */ svg * { pointer-events: none; } :host([interactive]) svg * { pointer-events: visiblePainted; } /* Text lets the pointer through to the band under it: WebKit fires no click on SVG text inside a shadow root. */ :host([interactive]) svg text, :host([interactive]) svg textPath { pointer-events: none; } /* The touch area catches the pointer even where the band is transparent. */ #orbitHit { fill: none; stroke: transparent; } :host([interactive]) #orbitHit { pointer-events: stroke; } :host([interactive]) { cursor: pointer; } #orbitShape { fill: var(--o-fill); stroke: var(--o-stroke); stroke-width: var(--o-stroke-width); transition: fill 0.25s, stroke 0.25s; } text { fill: var(--o-color); } /* Guides for text: never painted and never under the pointer. */ #orbitPath, #measure { fill: none; stroke: none; pointer-events: none; } </style> <svg viewBox="0 0 100 100"> <path id="orbitHit" vector-effect="non-scaling-stroke"></path> <path id="orbitShape" shape-rendering="geometricPrecision" vector-effect="non-scaling-stroke"></path> <path id="orbitPath" shape-rendering="geometricPrecision" vector-effect="non-scaling-stroke"></path> <text> <textPath href="#orbitPath" alignment-baseline="middle"></textPath> </text> <text id="measure" visibility="hidden" aria-hidden="true"></text> </svg> `; var OrbitArc = class extends OrbitBase { constructor() { super(); this.attachShadow({ mode: "open" }); this.shadowRoot.appendChild(template.content.cloneNode(true)); } update() { const attrs = this.getAttributes(); const { fitRange, stacked } = attrs; if (stacked) { const anchor = attrs.auto && this.hasAttribute("text-anchor") ? { middle: -0.5, end: -1 }[attrs.textAnchor] || 0 : 0; setLayoutProperty(this, "--o-arc-start", `${attrs.stackOffset + anchor * attrs.arcAngle}deg`); setLayoutProperty(this, "--o_stack", attrs.stackOffset + attrs.arcAngle); } else { this.style.removeProperty("--o-arc-start"); this.style.removeProperty("--o_stack"); } const orbitPath = this.shadowRoot.querySelector("#orbitPath"); const orbitShape = this.shadowRoot.querySelector("#orbitShape"); const textPath = this.shadowRoot.querySelector("textPath"); orbitShape.setAttribute("d", this.calculateArcParameters(attrs).dShape); orbitPath.setAttribute("d", this.calculateTextArcParameters(attrs).dPath); this.updateHitArea(this.shadowRoot.querySelector("#orbitHit"), attrs, 0, attrs.arcAngle); const textAnchor = attrs.textAnchor; if (textAnchor === "start") { textPath.setAttribute("startOffset", "0%"); textPath.setAttribute("text-anchor", "start"); } else if (textAnchor === "middle") { textPath.setAttribute("startOffset", "50%"); textPath.setAttribute("text-anchor", "middle"); } else if (textAnchor === "end") { textPath.setAttribute("startOffset", "100%"); textPath.setAttribute("text-anchor", "end"); } if (fitRange) { textPath.parentElement.setAttribute("textLength", orbitPath.getTotalLength()); } else { textPath.parentElement.removeAttribute("textLength"); } textPath.parentElement.style.fontSize = attrs.svgFontSize; textPath.textContent = this.textContent; if (!attrs.auto) this.warnIfTextOverflows(orbitPath, textPath, fitRange); } /** * Curved text has an explicit angular budget: glyphs past the end of the * arc path are clipped SILENTLY by SVG. Measure and warn so nobody loses * time to invisible truncation (~20 chars in 96° at default sizes). * fit-range squeezes the text to the path via textLength, so it never clips. */ warnIfTextOverflows(orbitPath, textPath, fitRange) { const raw = (this.textContent || "").trim(); if (!raw || fitRange || !this.isConnected) return; if (this._textFrame) this.ownerDocument.defaultView.cancelAnimationFrame(this._textFrame); this._textFrame = this.ownerDocument.defaultView.requestAnimationFrame(() => { this._textFrame = 0; if (!this.isConnected) return; try { const pathLen = orbitPath.getTotalLength(); const textLen = textPath.parentElement.getComputedTextLength(); if (pathLen > 0 && textLen > pathLen && this._truncWarned !== raw) { this._truncWarned = raw; console.warn( `[orbit] <o-arc> text "${raw.length > 34 ? raw.slice(0, 34) + "\u2026" : raw}" overflows its arc (${Math.round(textLen)} > ${Math.round(pathLen)} units) and will clip. Shorten the text, widen --o-range, add range="auto" to fit the arc to the text, or fit-range to squeeze it.` ); } } catch (e) { } }); } getAttributes() { const common = super.getCommonAttributes(this); const { style } = common; const range = Math.max(0, Math.min(360, this.readAngle(style, "--o-range", 360))); const flip = this.hasAttribute("flip") || this.classList.contains("flip"); const fitRange = this.hasAttribute("fit-range") || this.classList.contains("fit-range"); const length = common.orbitRadius * 24 / common.orbitNumber || 100; const textAnchor = this.getAttribute("text-anchor") || "middle"; const fontSize = style.fontSize || "16px"; const gap = Math.max(0, this.readNumber(style, "--o-gap", 1)); const svgFontSize = `calc(${fontSize} * (100 / (${length}) * (12 / var(--o-orbit-number)))`; const auto = this.getAttribute("range") === "auto"; const stacked = auto || this.hasAttribute("value"); let arcAngle; if (auto) arcAngle = this.measureTextAngle(svgFontSize, common.realRadius, gap); else if (this.hasAttribute("value")) arcAngle = super.getProgressAngle(range, this.readValue(), this.readMax()); else arcAngle = Math.max(0, Math.min(360, this.readAngle(style, "--o-angle", 0))); this._autoAngle = auto ? arcAngle : 0; let stackOffset = 0; if (stacked) { for (let prev = this.previousElementSibling; prev; prev = prev.previousElementSibling) { if (prev.localName !== "o-arc") continue; const previousStyle = this.ownerDocument.defaultView.getComputedStyle(prev); if (prev.getAttribute("range") === "auto") stackOffset += prev._autoAngle || 0; else if (prev.hasAttribute("value")) { const prevMax = prev.hasAttribute("max") ? Number(prev.getAttribute("max")) : 100; const prevRange = cssNumber(prev, previousStyle.getPropertyValue("--o-range"), range, "angle"); stackOffset += super.getProgressAngle(prevRange, Number(prev.getAttribute("value")), prevMax); } else stackOffset += cssNumber(prev, previousStyle.getPropertyValue("--o-angle"), 0, "angle"); } } return { ...common, gap, arcAngle, stackOffset, flip, fitRange, length, fontSize, svgFontSize, textAnchor, auto, stacked }; } /** range="auto": the angle that fits this arc's text, with half an em of room at each end. */ measureTextAngle(svgFontSize, radius, gap) { const text = (this.textContent || "").trim(); if (!text || !(radius > 0)) return 0; const measure = this.shadowRoot.querySelector("#measure"); measure.style.fontSize = svgFontSize; measure.textContent = text; let length = 0; try { length = measure.getComputedTextLength() + parseFloat(this.ownerDocument.defaultView.getComputedStyle(measure).fontSize); } catch (e) { } if (!(length > 0)) return 0; return Math.min(360, length / radius * 180 / Math.PI + gap); } calculateArcParameters(attrs) { const { arcAngle, realRadius, arcHeightPercentage, orbitNumber: orbitNumber2, shape, strokeWidth, arcHeight, gap } = attrs; if (!(arcAngle > 0) || !(attrs.orbitRadius > 0)) return { dShape: "" }; const params = super.calculateCommonArcParameters( arcAngle, realRadius, arcHeightPercentage, orbitNumber2, shape, strokeWidth, arcHeight, gap ); const dShape = super.generatePathData(shape, params); return { dShape }; } calculateTextArcParameters(attrs) { const { arcAngle, realRadius, gap, flip } = attrs; if (!(arcAngle > 0)) return { dPath: "" }; const adjustedGap = Math.min(gap * 0.5, arcAngle * 0.49); const start = (adjustedGap - 90) * Math.PI / 180; const end = (arcAngle - adjustedGap - 90) * Math.PI / 180; return { dPath: flip ? this.linePath(realRadius, end, start) : this.linePath(realRadius, start, end) }; } resolveAngle(cssExpression) { return cssNumber(this, cssExpression, 0, "angle"); } /** @deprecated Use resolveAngle(). */ calcularExpresionCSS(cssExpression) { return this.resolveAngle(cssExpression); } }; // src/js/orbit-resize.js var resizing = /* @__PURE__ */ new WeakMap(); var FIXED = /^[+-]?(?:\d+\.?\d*|\.\d+)px$/; var Orbit = { refresh: refreshLayout, /** A promise that resolves when pending layouts are drawn. */ ready: layoutReady, /** * Scale systems that use a fixed px --o-force with their container's width * (500px wide = ratio 1). Systems on the default --o-force already follow * their .bigbang, so they are left alone. */ resize(parentElementSelector) { var _a, _b; const parent = typeof parentElementSelector === "string" ? (_a = globalThis.document) == null ? void 0 : _a.querySelector(parentElementSelector) : parentElementSelector; if (!(parent == null ? void 0 : parent.ownerDocument)) { console.error("Orbit.resize: element not found:", parentElementSelector); return () => { }; } (_b = resizing.get(parent)) == null ? void 0 : _b(); const view = parent.ownerDocument.defaultView; const applyRatio = (width) => { if (!(width > 0)) return; let fixed = 0; for (const element of parent.querySelectorAll(".gravity-spot")) { if (!FIXED.test(view.getComputedStyle(element).getPropertyValue("--o-force").trim())) continue; fixed++; const ratio = String(width / 500); if (element.style.getPropertyValue("--o-force-ratio") !== ratio) element.style.setProperty("--o-force-ratio", ratio); } if (!fixed) { warnOnce(parent, "resize", "Orbit.resize() has nothing to do here: every .bigbang already scales with its container. It only scales systems that set a fixed px --o-force."); } refreshLayout(parent); }; const observer = new view.ResizeObserver((entries) => { for (const entry of entries) applyRatio(entry.contentRect.width); }); observer.observe(parent); applyRatio(parent.clientWidth || parent.getBoundingClientRect().width); const stop = () => { observer.disconnect(); if (resizing.get(parent) === stop) resizing.delete(parent); }; resizing.set(parent, stop); return stop; } }; // src/orbit.js function registerOrbit() { if (typeof customElements === "undefined" || typeof document === "undefined") return; if (!customElements.get("o-progress")) customElements.define("o-progress", OrbitProgress); if (!customElements.get("o-arc")) customElements.define("o-arc", OrbitArc); requestLayout(document); window.Orbit = Orbit; } registerOrbit(); })();