diff --git a/examples/app_hub/assets/continents-meta.json b/examples/app_hub/assets/continents-meta.json
new file mode 100644
index 0000000..1391f56
--- /dev/null
+++ b/examples/app_hub/assets/continents-meta.json
@@ -0,0 +1,50 @@
+{
+ "source": "mercator-source.jpg",
+ "size": [
+ 800,
+ 519
+ ],
+ "components": 8,
+ "layers": {
+ "vertical_snapshot": {
+ "translate": [
+ 0.0,
+ 238.0
+ ],
+ "scale": [
+ 1.2408477842003853,
+ 1.2408477842003853
+ ]
+ },
+ "inner": {
+ "translate": [
+ 223.66088631984587,
+ 238.0
+ ],
+ "scale": [
+ 1.2408477842003853,
+ 1.2408477842003853
+ ]
+ },
+ "outer": {
+ "scale": [
+ 1.0,
+ 1.0
+ ]
+ },
+ "pivot": [
+ 720.0,
+ 560.0
+ ]
+ },
+ "areas_top10": [
+ 78439,
+ 42423,
+ 21849,
+ 5520,
+ 5428,
+ 3434,
+ 546,
+ 540
+ ]
+}
\ No newline at end of file
diff --git a/examples/app_hub/assets/extract_red_polygons.py b/examples/app_hub/assets/extract_red_polygons.py
index bf3029e..a75a79e 100644
--- a/examples/app_hub/assets/extract_red_polygons.py
+++ b/examples/app_hub/assets/extract_red_polygons.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Extract red land polygons from Mercator reference image -> SVG paths."""
+"""Extract red land polygons from Mercator reference image -> layered SVG paths."""
from __future__ import annotations
@@ -14,10 +14,17 @@ from PIL import Image
ROOT = Path(__file__).resolve().parent
SRC = ROOT / "mercator-source.jpg"
OUT_FLAT = ROOT / "hub-logo-continents-mercator-flat.svg"
+OUT_GLOBE = ROOT / "hub-logo-continents-mercator-globe.svg"
+OUT_GLOBE_SNAP_VERTICAL = ROOT / "hub-logo-continents-mercator-globe.snapshot-vertical.svg"
OUT_META = ROOT / "continents-meta.json"
-# Source image is 800x519 Mercator-style plate.
-W, H = 800, 519
+# Globe framing in 1920x1080 logo scene.
+GLOBE_CX = 720.0
+GLOBE_CY = 560.0
+GLOBE_R = 322.0
+# Orthographic center (visible hemisphere).
+CENTER_LAT = 18.0
+CENTER_LON = 12.0
def red_mask(rgb: np.ndarray) -> np.ndarray:
@@ -26,7 +33,6 @@ def red_mask(rgb: np.ndarray) -> np.ndarray:
def dilate(mask: np.ndarray, radius: int = 3) -> np.ndarray:
- """Bridge thin white grid lines between red land cells."""
h, w = mask.shape
out = mask.copy()
ys, xs = np.where(mask)
@@ -61,18 +67,15 @@ def label_components(mask: np.ndarray) -> tuple[np.ndarray, int]:
def trace_boundary(labels: np.ndarray, label_id: int) -> list[tuple[int, int]]:
- """Moore-neighbor contour for one connected component."""
h, w = labels.shape
comp = labels == label_id
if not comp.any():
return []
- # Start at topmost-leftmost pixel of component.
ys, xs = np.where(comp)
start = (int(xs[0]), int(ys[0]))
x, y = start
path = [start]
- # Directions: E, SE, S, SW, W, NW, N, NE
dirs = [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
dir_idx = 0
@@ -123,35 +126,30 @@ def path_d(points: list[tuple[float, float]]) -> str:
def mercator_y_to_lat(y: float, h: float) -> float:
- # Map top->north, bottom->south; clamp to avoid infinities at poles.
t = 1.0 - (y / h)
t = min(max(t, 0.02), 0.98)
return math.degrees(math.atan(math.sinh(math.pi * (2 * t - 1))))
-def lat_lon_to_globe(lat: float, lon: float, cx: float, cy: float, r: float) -> tuple[float, float]:
- """Simple orthographic-ish placement on visible globe disk."""
+def lat_lon_to_globe(lat: float, lon: float) -> tuple[float, float]:
lat_r = math.radians(lat)
lon_r = math.radians(lon)
- # Visible hemisphere centered around lon 20, lat 20 for this logo framing.
- lon0 = math.radians(20.0)
- lat0 = math.radians(15.0)
- # Rotate lon relative to center.
+ lon0 = math.radians(CENTER_LON)
dlon = lon_r - lon0
- x = cx + r * math.cos(lat_r) * math.sin(dlon)
- y = cy - r * math.sin(lat_r)
+ x = GLOBE_CX + GLOBE_R * math.cos(lat_r) * math.sin(dlon)
+ y = GLOBE_CY - GLOBE_R * math.sin(lat_r)
return x, y
-def map_to_globe(points: list[tuple[int, int]], w: int, h: int) -> list[tuple[float, float]]:
- out: list[tuple[float, float]] = []
- cx, cy, r = 720.0, 560.0, 318.0
+def map_to_globe(points: list[tuple[int, int]], w: int, h: int) -> str:
+ mapped: list[tuple[float, float]] = []
for x, y in points:
lon = (x / w) * 360.0 - 180.0
lat = mercator_y_to_lat(float(y), float(h))
- gx, gy = lat_lon_to_globe(lat, lon, cx, cy, r)
- out.append((gx, gy))
- return out
+ mapped.append(lat_lon_to_globe(lat, lon))
+ if len(mapped) < 3:
+ return ""
+ return path_d(mapped)
def main() -> None:
@@ -164,31 +162,34 @@ def main() -> None:
components: list[dict] = []
for label_id in range(1, n + 1):
area = int((labels == label_id).sum())
- if area < 500: # drop tiny noise / watermark specks
+ if area < 500:
continue
boundary = trace_boundary(labels, label_id)
if len(boundary) < 20:
continue
boundary = simplify(boundary, min_dist=3.5)
+ flat = path_d([(float(x), float(y)) for x, y in boundary])
+ globe = map_to_globe(boundary, w, h)
+ if not flat:
+ continue
ys, xs = np.where(labels == label_id)
- bbox = [int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())]
components.append(
{
"id": label_id,
"area": area,
- "bbox": bbox,
- "flat": path_d([(float(x), float(y)) for x, y in boundary]),
- "globe": path_d(map_to_globe(boundary, w, h)),
+ "bbox": [int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())],
+ "flat": flat,
+ "globe": globe,
}
)
components.sort(key=lambda c: c["area"], reverse=True)
- flat_paths = "\n ".join(
+ flat_paths = "\n ".join(
f''
for c in components
)
- globe_paths = "\n ".join(
+ globe_paths = "\n ".join(
f''
for c in components
)
@@ -196,7 +197,7 @@ def main() -> None:
OUT_FLAT.write_text(
f"""
@@ -204,17 +205,73 @@ def main() -> None:
encoding="utf-8",
)
- globe_svg = ROOT.parent.parent / "examples" / "app_hub" / "assets" / "hub-logo-continents-mercator-globe.svg"
- globe_svg.write_text(
- f"""
diff --git a/examples/app_hub/assets/hub-thunder.svg b/examples/app_hub/assets/hub-thunder.svg
index 8cb8b54..8029789 100644
--- a/examples/app_hub/assets/hub-thunder.svg
+++ b/examples/app_hub/assets/hub-thunder.svg
@@ -1,22 +1,14 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
-
+
+
+
diff --git a/examples/app_hub/assets/hub-widget-resize-arrow.svg b/examples/app_hub/assets/hub-widget-resize-arrow.svg
new file mode 100644
index 0000000..1f95c7b
--- /dev/null
+++ b/examples/app_hub/assets/hub-widget-resize-arrow.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/examples/app_hub/assets/overtime-01-alphabet.svg b/examples/app_hub/assets/overtime-01-alphabet.svg
new file mode 100644
index 0000000..1bb41c0
--- /dev/null
+++ b/examples/app_hub/assets/overtime-01-alphabet.svg
@@ -0,0 +1,22 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/app_hub/clock-widget.html b/examples/app_hub/clock-widget.html
new file mode 100644
index 0000000..e142823
--- /dev/null
+++ b/examples/app_hub/clock-widget.html
@@ -0,0 +1,482 @@
+
+
+
+
+
+ Clock Widget
+
+
+
+
+
+
+
+
diff --git a/examples/app_hub/hub-logo-layers.css b/examples/app_hub/hub-logo-layers.css
index e71bd29..0906b6f 100644
--- a/examples/app_hub/hub-logo-layers.css
+++ b/examples/app_hub/hub-logo-layers.css
@@ -38,6 +38,26 @@
z-index: 4;
}
+.hub-logo-layer--compass {
+ z-index: 5;
+ left: 54.7%;
+ top: 75.9%;
+ width: 11.5%;
+ height: auto;
+ inset: auto;
+ transform: translate(-50%, -50%);
+ pointer-events: auto;
+ cursor: pointer;
+ transition: transform 0.22s ease, filter 0.22s ease;
+}
+
+.hub-logo-layer--compass:hover,
+.hub-logo-layer--compass:focus-visible {
+ transform: translate(-50%, -50%) scale(1.12);
+ filter: drop-shadow(0 0 16px rgba(186, 228, 255, 0.55));
+ outline: none;
+}
+
.hub-logo-layer--tail {
z-index: 5;
}
diff --git a/examples/app_hub/hub.css b/examples/app_hub/hub.css
index 17bcd6f..5808db4 100644
--- a/examples/app_hub/hub.css
+++ b/examples/app_hub/hub.css
@@ -169,70 +169,307 @@
z-index: 1;
}
-.hub-compass-hit {
+.hub-clock-widget {
position: fixed;
- left: calc(50% + 252px);
- top: calc(50% + 226px);
- width: 110px;
- height: 110px;
- z-index: 2;
- border-radius: 50%;
- border: 2px solid rgba(219, 229, 241, 0.45);
- background:
- radial-gradient(circle at 35% 35%, rgba(231, 236, 243, 0.35), rgba(10, 14, 20, 0.22) 68%, rgba(10, 14, 20, 0.1) 100%);
- box-shadow:
- -6px -8px 8px rgba(160, 215, 255, 0.12),
- 8px 10px 14px rgba(9, 13, 19, 0.24);
- cursor: pointer;
- transform: translate(-50%, -50%) scale(1);
- transition: transform 0.22s ease, border-width 0.22s ease, box-shadow 0.22s ease;
+ left: calc(50% + 290px);
+ top: calc(50% - 300px);
+ width: min(420px, 42vw);
+ min-width: 330px;
+ z-index: 3;
+ border: 1px solid rgba(255, 255, 255, 0.72);
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.48);
+ border-radius: 18px;
+ overflow: hidden;
+ background: linear-gradient(180deg, rgba(18, 24, 34, 0.92), rgba(13, 18, 28, 0.9));
+ backdrop-filter: blur(1.5px);
+ transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease;
+ cursor: move;
+ user-select: none;
+ touch-action: none;
+ padding: 30px;
+ box-sizing: border-box;
}
-.hub-compass-hit::before,
-.hub-compass-hit::after {
- content: "";
+.hub-clock-widget:hover {
+ transform: scale(1.035);
+ box-shadow: 0 16px 42px rgba(0, 0, 0, 0.62), 0 0 26px rgba(155, 205, 255, 0.22);
+ filter: saturate(1.04);
+}
+
+.cw-row {
+ width: 100%;
+ display: grid;
+ border-top: 1px solid rgba(255, 255, 255, 0.32);
+}
+
+.cw-row:first-child { border-top: 0; }
+.cw-row-main { grid-template-columns: 1fr; }
+.cw-row-mid { grid-template-columns: repeat(4, 1fr); }
+.cw-row-world { grid-template-columns: repeat(3, 1fr); }
+
+.cw-cell {
+ border-left: 1px solid rgba(255, 255, 255, 0.32);
+ min-height: 42px;
+ padding: 6px;
+}
+
+.cw-cell:first-child { border-left: 0; }
+
+.cw-main-cell {
+ min-height: 168px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ gap: 6px;
+ padding: 0;
+ background: linear-gradient(180deg, rgba(30, 40, 28, 0.85), rgba(20, 28, 20, 0.85));
+}
+
+.cw-line-time {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+}
+
+.cw-line-time .cw-time-main {
+ font-weight: 800;
+}
+
+.cw-time-main {
+ display: inline-flex;
+ align-items: flex-end;
+ justify-content: center;
+ gap: 2px;
+ line-height: 1;
+ font-weight: 700;
+ width: 100%;
+ max-width: 100%;
+}
+
+.ot-lcd-line {
+ display: inline-flex;
+ align-items: flex-end;
+ gap: 4px;
+}
+
+.ot-lcd-bold .ot-glyph-wrap--bold {
+ transform: scale(1.12, 1.2);
+}
+
+#cw-hm-lcd .ot-glyph {
+ height: 68px;
+ width: auto;
+}
+
+.ot-lcd-sec {
+ margin-left: 8px;
+}
+
+.ot-lcd-sec .ot-glyph-wrap:first-child {
+ margin-right: 8px;
+}
+
+.ot-lcd-sec .ot-glyph {
+ height: 32px;
+ width: auto;
+}
+
+.ot-lcd-tz .ot-glyph {
+ height: 30px;
+ width: auto;
+}
+
+.ot-lcd-tz {
+ margin-left: 14px;
+}
+
+.ot-glyph-wrap {
+ display: inline-block;
+ line-height: 0;
+ transform-origin: bottom center;
+}
+
+.ot-glyph-wrap--bold {
+ transform: scale(1.08, 1.14);
+}
+
+.ot-glyph {
+ fill: #d8f6b8;
+ filter: drop-shadow(0 0 5px rgba(190, 240, 150, 0.38));
+}
+
+.ot-glyph .ot-grid rect {
+ fill: rgba(20, 30, 24, 0.85);
+ filter: drop-shadow(0 0 1.4px rgba(135, 205, 255, 0.5));
+}
+
+.ot-glyph .ot-active {
+ fill: #d8f6b8;
+}
+
+.ot-colon-blink {
+ animation: cwBlink 1.25s linear infinite;
+}
+
+.cw-line-region {
+ color: #b8c2d4;
+ font-size: 10px;
+ letter-spacing: 0.16em;
+ font-weight: 300;
+ text-transform: uppercase;
+ width: 100%;
+ text-align: center;
+}
+
+.cw-line-date {
+ color: #c5d0e2;
+ font-size: 10px;
+ letter-spacing: 0.12em;
+ font-weight: 400;
+ text-transform: uppercase;
+ width: 100%;
+ text-align: center;
+}
+
+.cw-row-mid {
+ display: none;
+}
+
+.cw-world-cell {
+ min-height: 178px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 5px;
+ padding: 25px 6px;
+ background: rgba(20, 28, 40, 0.62);
+}
+
+.cw-world-label {
+ color: #a6b0c2;
+ font-size: 9.5px;
+ letter-spacing: 0.12em;
+ margin-top: 0;
+ margin-bottom: 8px;
+}
+
+.cw-dial {
+ width: 95px;
+ height: 95px;
+ border-radius: 50%;
+ position: relative;
+ border: 2px solid rgba(239, 243, 252, 0.8);
+ background: radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.36), rgba(255, 255, 255, 0) 36%), #edf2fb;
+ box-shadow: inset -5px -5px 10px rgba(0, 0, 0, 0.14), inset 6px 7px 9px rgba(255, 255, 255, 0.42);
+}
+
+.cw-roman {
position: absolute;
left: 50%;
top: 50%;
- transform: translate(-50%, -50%);
- border-radius: 1px;
- background: rgba(231, 236, 243, 0.82);
- transition: width 0.22s ease, height 0.22s ease;
+ width: 15px;
+ height: 15px;
+ margin: -7.5px;
+ transform: rotate(calc(var(--i) * 30deg)) translateY(-34px) rotate(calc(var(--i) * -30deg));
+ font-family: "Times New Roman", serif;
+ font-size: 9px;
+ font-weight: 700;
+ color: #223147;
+ text-align: center;
}
-.hub-compass-hit::before {
- width: 3px;
- height: 46px;
+.cw-roman-major {
+ font-size: 11.5px;
+ opacity: 1;
}
-.hub-compass-hit::after {
- width: 46px;
- height: 3px;
+.cw-roman-minor {
+ font-size: 3.2px;
+ opacity: 0.25;
}
-.hub-compass-hit:hover,
-.hub-compass-hit:focus-visible {
- transform: translate(-50%, -50%) scale(1.25);
- border-width: 2.3px;
- box-shadow:
- -8px -10px 10px rgba(180, 228, 255, 0.22),
- 14px 16px 22px rgba(7, 10, 16, 0.34),
- 22px 26px 34px rgba(6, 8, 14, 0.42);
- outline: none;
+.cw-resize-handle {
+ position: absolute;
+ width: 32px;
+ height: 32px;
+ z-index: 6;
+ display: grid;
+ color: rgba(220, 236, 255, 0.9);
+ filter: drop-shadow(0 0 4px rgba(150, 200, 255, 0.35));
+ pointer-events: auto;
+ background: transparent;
}
-.hub-compass-hit:hover::before,
-.hub-compass-hit:hover::after,
-.hub-compass-hit:focus-visible::before,
-.hub-compass-hit:focus-visible::after {
- width: 52px;
- height: 3.45px;
+.cw-resize-handle svg {
+ width: 16px;
+ height: 16px;
+ fill: currentColor;
+ pointer-events: none;
}
-.hub-compass-hit:hover::before,
-.hub-compass-hit:focus-visible::before {
- width: 3.45px;
- height: 52px;
+.cw-resize-sw {
+ left: 0;
+ bottom: 0;
+ cursor: nesw-resize;
+ place-items: end start;
+}
+
+.cw-resize-nw {
+ left: 0;
+ top: 0;
+ cursor: nwse-resize;
+ place-items: start start;
+ transform: scaleY(-1);
+}
+
+.cw-resize-ne {
+ right: 0;
+ top: 0;
+ cursor: nesw-resize;
+ place-items: start end;
+ transform: scale(-1, -1);
+}
+
+.cw-resize-se {
+ right: 0;
+ bottom: 0;
+ cursor: nwse-resize;
+ place-items: end end;
+ transform: scale(-1, 1);
+}
+
+.cw-hand {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ transform-origin: 50% 100%;
+ transform: translate(-50%, -100%) rotate(0deg);
+ border-radius: 99px;
+}
+
+.cw-hour { width: 4px; height: 26%; background: #1f2f46; }
+.cw-minute { width: 3px; height: 35%; background: #2e4767; }
+.cw-second { width: 1.5px; height: 39%; background: #b63b3b; }
+
+.cw-pin {
+ position: absolute;
+ left: 50%;
+ top: 50%;
+ width: 7px;
+ height: 7px;
+ margin-left: -3.5px;
+ margin-top: -3.5px;
+ border-radius: 50%;
+ background: #101928;
+ border: 1px solid #f3f6ff;
+}
+
+
+@keyframes cwBlink {
+ 0%, 20% { opacity: 0; }
+ 20.01%, 60% { opacity: 1; }
+ 60.01%, 100% { opacity: 0; }
}
.hub-docs-modal {
@@ -252,12 +489,89 @@
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
- width: min(980px, calc(100vw - 40px));
- max-height: calc(100vh - 56px);
+ width: 90vw;
+ height: 90vh;
+ max-width: 90vw;
+ max-height: 90vh;
display: flex;
flex-direction: column;
}
+.hub-docs-layout {
+ display: grid;
+ grid-template-columns: minmax(220px, 28%) 1fr;
+ gap: 0;
+ flex: 1;
+ min-height: 0;
+ border-top: 1px solid var(--border);
+}
+
+.hub-docs-toc {
+ margin: 0;
+ border-radius: 0;
+ border: 0;
+ border-right: 1px solid var(--border);
+ overflow: auto;
+ padding: 10px;
+}
+
+.hub-docs-toc-title {
+ margin: 0 0 8px;
+ font-size: 12px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.hub-docs-toc-group + .hub-docs-toc-group {
+ margin-top: 10px;
+ padding-top: 8px;
+ border-top: 1px solid var(--border);
+}
+
+.hub-docs-toc-head,
+.hub-docs-toc-leaf {
+ display: block;
+ width: 100%;
+ text-align: left;
+ border: 0;
+ background: transparent;
+ color: var(--text);
+ cursor: pointer;
+ padding: 8px 10px;
+ border-radius: 8px;
+ font: inherit;
+}
+
+.hub-docs-toc-head {
+ font-weight: 700;
+ letter-spacing: 0.04em;
+}
+
+.hub-docs-toc-leaf {
+ margin-left: 8px;
+ font-weight: 600;
+ font-size: 13px;
+}
+
+.hub-docs-toc-head:hover,
+.hub-docs-toc-leaf:hover,
+.hub-docs-toc-head:focus-visible,
+.hub-docs-toc-leaf:focus-visible {
+ background: var(--row-hover);
+ outline: none;
+}
+
+.hub-docs-panels {
+ overflow: auto;
+ padding: 10px 12px 14px;
+ min-height: 0;
+}
+
+.hub-docs-section .detail-tree-body {
+ font-size: 14px;
+ line-height: 1.5;
+}
+
.hub-docs-header {
display: flex;
align-items: center;
@@ -321,6 +635,13 @@
}
@media (max-width: 860px) {
+ .hub-clock-widget {
+ left: auto;
+ right: 10px;
+ top: 10px;
+ width: min(94vw, 420px);
+ min-width: 0;
+ }
.hub-cards {
grid-template-columns: 1fr;
max-width: 420px;
diff --git a/examples/app_hub/index.html b/examples/app_hub/index.html
index 0070a26..54ff47a 100644
--- a/examples/app_hub/index.html
+++ b/examples/app_hub/index.html
@@ -11,12 +11,34 @@
})();
-
+
+
-
+
@@ -24,8 +46,11 @@
Deployment model
-
@@ -35,7 +60,6 @@
Android Cast
-
Project hub