1
0
mirror of git://f0xx.org/android_cast synced 2026-07-29 05:17:39 +03:00
This commit is contained in:
Anton Afanasyeu
2026-05-27 22:53:49 +02:00
parent 43f802de5d
commit 2b7397edd2
16 changed files with 1843 additions and 262 deletions

View File

@@ -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
]
}

View File

@@ -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'<path id="continent-{c["id"]}" data-area="{c["area"]}" d="{c["flat"]}"/>'
for c in components
)
globe_paths = "\n ".join(
globe_paths = "\n ".join(
f'<path id="continent-{c["id"]}" data-area="{c["area"]}" d="{c["globe"]}"/>'
for c in components
)
@@ -196,7 +197,7 @@ def main() -> None:
OUT_FLAT.write_text(
f"""<svg xmlns="http://www.w3.org/2000/svg" width="{w}" height="{h}" viewBox="0 0 {w} {h}" role="img" aria-label="Extracted continents (flat Mercator plate)">
<rect width="100%" height="100%" fill="#b8d4e8"/>
<g id="continents-extracted" fill="#c41e1a" fill-opacity="0.92" stroke="#8f1410" stroke-width="0.8">
<g id="continents-inner-layer" fill="#9ab3cc" fill-opacity="0.82" stroke="#dce8f6" stroke-opacity="0.78" stroke-width="1.1">
{flat_paths}
</g>
</svg>
@@ -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"""<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Extracted continents mapped to globe viewBox">
# Two-step alignment:
# 1) Vertical fit: Mercator north/south poles match globe top/bottom.
# 2) Horizontal fit: layer centers/pivots match at globe center.
globe_diameter = GLOBE_R * 2
inner_sy = globe_diameter / h
inner_sx = inner_sy
inner_ty = GLOBE_CY - GLOBE_R
inner_tx_vertical = 0.0
inner_tx = GLOBE_CX - (w * inner_sx) / 2
outer_sx = 1.0
outer_sy = 1.0
def globe_svg(
inner_tx_val: float,
inner_ty_val: float,
inner_sx_val: float,
inner_sy_val: float,
outer_sx_val: float,
outer_sy_val: float,
label: str,
) -> str:
return f"""<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid slice" role="img" aria-label="{label}">
<defs>
<clipPath id="globeClip"><circle cx="720" cy="560" r="322"/></clipPath>
<clipPath id="globeClip"><circle cx="{GLOBE_CX:.0f}" cy="{GLOBE_CY:.0f}" r="{GLOBE_R:.0f}"/></clipPath>
<radialGradient id="continentRelief" cx="34%" cy="22%" r="78%">
<stop offset="0%" stop-color="#dbe7f6" stop-opacity="0.45"/>
<stop offset="58%" stop-color="#9ab3cc" stop-opacity="0.22"/>
<stop offset="100%" stop-color="#6a7f97" stop-opacity="0.18"/>
</radialGradient>
<filter id="continentDepth" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="-4" dy="-6" stdDeviation="3" flood-color="#f4fbff" flood-opacity="0.2"/>
<feDropShadow dx="4" dy="6" stdDeviation="4" flood-color="#0a1220" flood-opacity="0.28"/>
</filter>
</defs>
<g id="continents-globe" clip-path="url(#globeClip)" fill="#7287a1" fill-opacity="0.88" stroke="#a8b8cc" stroke-opacity="0.45" stroke-width="1">
{globe_paths}
<g id="continents-outer-layer" clip-path="url(#globeClip)" transform="translate({GLOBE_CX:.0f} {GLOBE_CY:.0f}) scale({outer_sx_val:.3f} {outer_sy_val:.3f}) translate(-{GLOBE_CX:.0f} -{GLOBE_CY:.0f})">
<g id="continents-inner-layer" transform="translate({inner_tx_val:.2f} {inner_ty_val:.2f}) scale({inner_sx_val:.4f} {inner_sy_val:.4f})">
<g id="continents-flat" fill="url(#continentRelief)" fill-opacity="0.9" stroke="#dce8f6" stroke-opacity="0.78" stroke-width="1.2" filter="url(#continentDepth)">
{flat_paths}
</g>
</g>
</g>
</svg>
""",
"""
OUT_GLOBE_SNAP_VERTICAL.write_text(
globe_svg(
inner_tx_vertical,
inner_ty,
inner_sx,
inner_sy,
outer_sx,
outer_sy,
"Continents vertical-fit snapshot",
),
encoding="utf-8",
)
OUT_GLOBE.write_text(
globe_svg(
inner_tx,
inner_ty,
inner_sx,
inner_sy,
outer_sx,
outer_sy,
"Continents layered Mercator overlay",
),
encoding="utf-8",
)
@@ -224,6 +281,15 @@ def main() -> None:
"source": str(SRC.name),
"size": [w, h],
"components": len(components),
"layers": {
"vertical_snapshot": {
"translate": [inner_tx_vertical, inner_ty],
"scale": [inner_sx, inner_sy],
},
"inner": {"translate": [inner_tx, inner_ty], "scale": [inner_sx, inner_sy]},
"outer": {"scale": [outer_sx, outer_sy]},
"pivot": [GLOBE_CX, GLOBE_CY],
},
"areas_top10": [c["area"] for c in components[:10]],
},
indent=2,
@@ -233,7 +299,7 @@ def main() -> None:
print(f"components={len(components)}")
print(f"wrote {OUT_FLAT}")
print(f"wrote {globe_svg}")
print(f"wrote {OUT_GLOBE}")
if __name__ == "__main__":

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080" preserveAspectRatio="xMidYMid slice" role="img" aria-label="Compass layer">
<g id="globe-compass" transform="translate(1050 820)">
<circle cx="0" cy="0" r="52" fill="#0a0e14" fill-opacity="0.42" stroke="#c7d3e1" stroke-opacity="0.52" stroke-width="2"/>
<circle cx="0" cy="0" r="40" fill="none" stroke="#dbe5f1" stroke-opacity="0.64" stroke-width="1.5"/>
<path d="M 0 -38 L 9 0 L 0 38 L -9 0 Z" fill="#e7ecf3" fill-opacity="0.9"/>
<path d="M -38 0 L 0 -9 L 38 0 L 0 9 Z" fill="#8ea1b9" fill-opacity="0.86"/>
<path d="M 0 -28 L 6 0 L 0 28 L -6 0 Z" fill="#223246" fill-opacity="0.9"/>
<circle cx="0" cy="0" r="4.2" fill="#e7ecf3" fill-opacity="0.92"/>
<text x="0" y="-56" text-anchor="middle" fill="#e7ecf3" fill-opacity="0.72" font-size="12" font-family="Segoe UI, sans-serif">N</text>
<text x="0" y="68" text-anchor="middle" fill="#a9bbce" fill-opacity="0.68" font-size="12" font-family="Segoe UI, sans-serif">S</text>
<text x="-66" y="4" text-anchor="middle" fill="#a9bbce" fill-opacity="0.68" font-size="12" font-family="Segoe UI, sans-serif">W</text>
<text x="66" y="4" text-anchor="middle" fill="#a9bbce" fill-opacity="0.68" font-size="12" font-family="Segoe UI, sans-serif">E</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -31,18 +31,4 @@
<ellipse id="globe-south-pole" cx="720" cy="728" rx="260" ry="39" stroke="url(#gradSouthPole)" stroke-width="7.5"/>
</g>
<g id="globe-compass" transform="translate(1050 820)">
<circle cx="0" cy="0" r="52" fill="#0a0e14" fill-opacity="0.42" stroke="#c7d3e1" stroke-opacity="0.52" stroke-width="2"/>
<circle cx="0" cy="0" r="40" fill="none" stroke="#dbe5f1" stroke-opacity="0.64" stroke-width="1.5"/>
<path d="M 0 -38 L 9 0 L 0 38 L -9 0 Z" fill="#e7ecf3" fill-opacity="0.9"/>
<path d="M -38 0 L 0 -9 L 38 0 L 0 9 Z" fill="#8ea1b9" fill-opacity="0.86"/>
<path d="M 0 -28 L 6 0 L 0 28 L -6 0 Z" fill="#223246" fill-opacity="0.9"/>
<circle cx="0" cy="0" r="4.2" fill="#e7ecf3" fill-opacity="0.92"/>
<text x="0" y="-56" text-anchor="middle" fill="#e7ecf3" fill-opacity="0.72" font-size="12" font-family="Segoe UI, sans-serif">N</text>
<text x="0" y="68" text-anchor="middle" fill="#a9bbce" fill-opacity="0.68" font-size="12" font-family="Segoe UI, sans-serif">S</text>
<text x="-66" y="4" text-anchor="middle" fill="#a9bbce" fill-opacity="0.68" font-size="12" font-family="Segoe UI, sans-serif">W</text>
<text x="66" y="4" text-anchor="middle" fill="#a9bbce" fill-opacity="0.68" font-size="12" font-family="Segoe UI, sans-serif">E</text>
</g>
<circle id="globe-border" cx="720" cy="560" r="330" fill="none" stroke="#e7ecf3" stroke-width="15"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -27,6 +27,14 @@
<stop offset="52%" stop-color="#d7e0ec" stop-opacity="0.78"/>
<stop offset="100%" stop-color="#4f6178" stop-opacity="0.92"/>
</linearGradient>
<linearGradient id="sunRim" x1="390" y1="560" x2="1050" y2="560" gradientUnits="userSpaceOnUse">
<stop offset="0%" stop-color="#f5f8ff" stop-opacity="0.98"/>
<stop offset="38%" stop-color="#dce7f5" stop-opacity="0.82"/>
<stop offset="100%" stop-color="#4a5c72" stop-opacity="0.7"/>
</linearGradient>
<filter id="sunRimGlow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur stdDeviation="3.2"/>
</filter>
<clipPath id="gridClip"><circle cx="720" cy="560" r="322"/></clipPath>
</defs>
@@ -45,18 +53,6 @@
<ellipse id="globe-south-pole" cx="720" cy="728" rx="260" ry="39" stroke="url(#gradSouthPole)" stroke-width="7.5"/>
</g>
<g id="globe-compass" transform="translate(1050 820)">
<circle cx="0" cy="0" r="52" fill="#0a0e14" fill-opacity="0.42" stroke="#c7d3e1" stroke-opacity="0.52" stroke-width="2"/>
<circle cx="0" cy="0" r="40" fill="none" stroke="#dbe5f1" stroke-opacity="0.64" stroke-width="1.5"/>
<path d="M 0 -38 L 9 0 L 0 38 L -9 0 Z" fill="#e7ecf3" fill-opacity="0.9"/>
<path d="M -38 0 L 0 -9 L 38 0 L 0 9 Z" fill="#8ea1b9" fill-opacity="0.86"/>
<path d="M 0 -28 L 6 0 L 0 28 L -6 0 Z" fill="#223246" fill-opacity="0.9"/>
<circle cx="0" cy="0" r="4.2" fill="#e7ecf3" fill-opacity="0.92"/>
<text x="0" y="-56" text-anchor="middle" fill="#e7ecf3" fill-opacity="0.72" font-size="12" font-family="Segoe UI, sans-serif">N</text>
<text x="0" y="68" text-anchor="middle" fill="#a9bbce" fill-opacity="0.68" font-size="12" font-family="Segoe UI, sans-serif">S</text>
<text x="-66" y="4" text-anchor="middle" fill="#a9bbce" fill-opacity="0.68" font-size="12" font-family="Segoe UI, sans-serif">W</text>
<text x="66" y="4" text-anchor="middle" fill="#a9bbce" fill-opacity="0.68" font-size="12" font-family="Segoe UI, sans-serif">E</text>
</g>
<circle id="globe-border" cx="720" cy="560" r="330" fill="none" stroke="#e7ecf3" stroke-width="15"/>
<circle id="globe-border-glow" cx="720" cy="560" r="330" fill="none" stroke="url(#sunRim)" stroke-opacity="0.75" stroke-width="20" filter="url(#sunRimGlow)"/>
<circle id="globe-border" cx="720" cy="560" r="330" fill="none" stroke="url(#sunRim)" stroke-width="14"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

@@ -1,22 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="340" height="46" viewBox="0 0 340 46" role="img" aria-label="Thunder schematic underline">
<defs>
<linearGradient id="thunderCore" x1="0%" y1="50%" x2="100%" y2="50%">
<stop offset="0%" stop-color="#7dd3fc" stop-opacity="0.15"/>
<stop offset="15%" stop-color="#bae6fd" stop-opacity="0.95"/>
<stop offset="48%" stop-color="#ffffff" stop-opacity="1"/>
<stop offset="82%" stop-color="#bae6fd" stop-opacity="0.95"/>
<stop offset="100%" stop-color="#7dd3fc" stop-opacity="0.15"/>
</linearGradient>
<linearGradient id="thunderEdge" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#9ddcff" stop-opacity="0"/>
<stop offset="24%" stop-color="#9ddcff" stop-opacity="0.8"/>
<stop offset="76%" stop-color="#9ddcff" stop-opacity="0.8"/>
<stop offset="100%" stop-color="#9ddcff" stop-opacity="0"/>
<linearGradient id="thunderFill" x1="0%" y1="50%" x2="100%" y2="50%">
<stop offset="0%" stop-color="#8ecfff" stop-opacity="0.22"/>
<stop offset="18%" stop-color="#c9ecff" stop-opacity="0.98"/>
<stop offset="50%" stop-color="#ffffff" stop-opacity="1"/>
<stop offset="82%" stop-color="#c9ecff" stop-opacity="0.98"/>
<stop offset="100%" stop-color="#8ecfff" stop-opacity="0.22"/>
</linearGradient>
</defs>
<path d="M 12 24 L 86 24 L 98 16 L 126 31 L 154 13 L 178 28 L 210 11 L 236 27 L 262 17 L 286 25 L 328 25"
fill="none" stroke="url(#thunderCore)" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 12 24 L 86 24 L 98 16 L 126 31 L 154 13 L 178 28 L 210 11 L 236 27 L 262 17 L 286 25 L 328 25"
fill="none" stroke="url(#thunderEdge)" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M 10 9 L 196 9" fill="none" stroke="url(#thunderFill)" stroke-width="5.5" stroke-linecap="round"/>
<path d="M 196 9 L 248 31" fill="none" stroke="url(#thunderFill)" stroke-width="5.5" stroke-linecap="round"/>
<path d="M 248 31 L 330 31" fill="none" stroke="url(#thunderFill)" stroke-width="5.5" stroke-linecap="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 932 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.279 45.279" aria-hidden="true">
<symbol id="resizeArrow" viewBox="0 0 45.279 45.279">
<path d="M44.106,42.943H3.987L44.933,1.995c0.456-0.455,0.456-1.196,0-1.651c-0.452-0.455-1.195-0.455-1.651,0L2.336,41.292V1.168
C2.336,0.524,1.81,0,1.168,0C0.523,0,0,0.524,0,1.168v44.111h44.111c0.644,0,1.168-0.522,1.168-1.168
C45.279,43.469,44.755,42.943,44.106,42.943z"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 451 B

View File

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" style="position:absolute;width:0;height:0;overflow:hidden">
<!-- Overtime 01 alphabet: 7-segment LCD glyphs for hub primary clock -->
<defs>
<symbol id="ot-0" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/><rect x="1" y="17" width="3" height="11" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/></symbol>
<symbol id="ot-1" viewBox="0 0 20 32"><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/></symbol>
<symbol id="ot-2" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/><rect x="1" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/></symbol>
<symbol id="ot-3" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/></symbol>
<symbol id="ot-4" viewBox="0 0 20 32"><rect x="1" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/></symbol>
<symbol id="ot-5" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/></symbol>
<symbol id="ot-6" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/><rect x="1" y="17" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/></symbol>
<symbol id="ot-7" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/></symbol>
<symbol id="ot-8" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/><rect x="1" y="17" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/></symbol>
<symbol id="ot-9" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/><rect x="3" y="15" width="14" height="3" rx="1"/><rect x="16" y="4" width="3" height="11" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/></symbol>
<symbol id="ot-colon" viewBox="0 0 12 32"><rect x="4" y="10" width="4" height="4" rx="1"/><rect x="4" y="20" width="4" height="4" rx="1"/></symbol>
<symbol id="ot-plus" viewBox="0 0 16 32"><rect x="6" y="8" width="4" height="16" rx="1"/><rect x="2" y="14" width="12" height="4" rx="1"/></symbol>
<symbol id="ot-minus" viewBox="0 0 16 32"><rect x="2" y="14" width="12" height="4" rx="1"/></symbol>
<symbol id="ot-space" viewBox="0 0 8 32"></symbol>
<symbol id="ot-G" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="1" y="4" width="3" height="11" rx="1"/><rect x="1" y="17" width="3" height="11" rx="1"/><rect x="3" y="28" width="14" height="3" rx="1"/><rect x="16" y="17" width="3" height="11" rx="1"/><rect x="10" y="15" width="9" height="3" rx="1"/></symbol>
<symbol id="ot-M" viewBox="0 0 24 32"><rect x="1" y="4" width="3" height="24" rx="1"/><rect x="20" y="4" width="3" height="24" rx="1"/><rect x="7.1" y="5" width="2.6" height="13.2" rx="1" transform="rotate(28 8.4 11.6)"/><rect x="14.3" y="5" width="2.6" height="13.2" rx="1" transform="rotate(-28 15.6 11.6)"/></symbol>
<symbol id="ot-T" viewBox="0 0 20 32"><rect x="3" y="1" width="14" height="3" rx="1"/><rect x="8" y="4" width="4" height="24" rx="1"/></symbol>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -0,0 +1,482 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clock Widget</title>
<style>
:root {
--bg: #0f1218;
--panel: #171c24;
--panel-2: #131820;
--border: rgba(255, 255, 255, 0.72);
--text: #eef3ff;
--muted: #9aa5ba;
--accent: #dbe7ff;
--digit: #d8f6b8;
--digit-dim: #76906a;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: radial-gradient(circle at 20% 15%, #1f2a3b 0%, var(--bg) 50%, #090c12 100%);
color: var(--text);
font-family: "Segoe UI", Tahoma, sans-serif;
padding: 18px;
}
.clock-widget {
width: min(980px, 100%);
border: 1px solid var(--border);
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.08) inset;
background: linear-gradient(180deg, #141a22 0%, #0f131a 100%);
border-radius: 8px;
overflow: hidden;
}
.row {
width: 100%;
display: grid;
border-top: 1px solid rgba(255, 255, 255, 0.3);
}
.row:first-child {
border-top: 0;
}
.cell {
border-left: 1px solid rgba(255, 255, 255, 0.3);
min-height: 72px;
padding: 10px;
background: var(--panel);
}
.cell:first-child {
border-left: 0;
}
.row-1 {
grid-template-columns: 1fr;
}
.row-2 {
grid-template-columns: repeat(4, 1fr);
}
.row-3 {
grid-template-columns: repeat(3, 1fr);
}
.main-clock {
min-height: 220px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.03), rgba(255, 255, 255, 0)),
repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.02) 0,
rgba(255, 255, 255, 0.02) 1px,
transparent 1px,
transparent 6px
),
#111822;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 10px;
text-align: center;
}
.primary-zone {
color: var(--muted);
letter-spacing: 0.14em;
font-weight: 600;
font-size: 0.82rem;
text-transform: uppercase;
}
.primary-time {
display: inline-flex;
align-items: flex-end;
gap: 6px;
padding: 10px 16px 8px;
border: 1px solid rgba(220, 235, 180, 0.45);
border-radius: 6px;
background: linear-gradient(180deg, #202a1e 0%, #181f16 100%);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04), 0 8px 18px rgba(0, 0, 0, 0.45);
font-family: "Overtime LCD", "DS-Digital", "Digital-7 Mono", "Consolas", monospace;
font-variant-numeric: tabular-nums;
color: var(--digit);
text-shadow: 0 0 5px rgba(190, 240, 150, 0.35), 0 0 1px rgba(180, 220, 145, 0.7);
letter-spacing: 0.06em;
line-height: 1;
}
.primary-time .hh,
.primary-time .mm {
font-size: clamp(3.1rem, 9.2vw, 5.7rem);
font-weight: 500;
}
.primary-time .colon {
display: inline-block;
width: 0.55em;
text-align: center;
font-size: clamp(3.1rem, 9.2vw, 5.7rem);
animation: casioBlink 1.25s linear infinite;
}
.primary-time .ss {
min-width: 2ch;
font-size: clamp(1.35rem, 3.1vw, 2rem);
margin-left: 2px;
margin-bottom: 0.5em;
color: var(--digit);
}
.date-cell {
display: flex;
justify-content: center;
align-items: center;
background: var(--panel-2);
color: #e8ecf6;
font-family: "Consolas", "Menlo", monospace;
font-size: clamp(0.92rem, 1.5vw, 1.1rem);
letter-spacing: 0.08em;
font-weight: 700;
text-transform: uppercase;
text-align: center;
}
.placeholder {
background: linear-gradient(180deg, #161d27 0%, #121923 100%);
}
.analog-cell {
min-height: 250px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: center;
gap: 12px;
background: radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0) 55%), #121923;
}
.tz-label {
margin-top: 2px;
font-size: 0.82rem;
letter-spacing: 0.14em;
color: var(--muted);
text-transform: uppercase;
font-weight: 700;
}
.wall-clock {
width: min(94%, 220px);
aspect-ratio: 1 / 1;
border-radius: 50%;
position: relative;
border: 4px solid rgba(239, 243, 252, 0.78);
background:
radial-gradient(circle at 35% 30%, rgba(255, 255, 255, 0.34), rgba(255, 255, 255, 0) 36%),
radial-gradient(circle at 50% 50%, #f6f8fc 0%, #e5ebf6 62%, #d3dceb 100%);
box-shadow:
0 12px 24px rgba(0, 0, 0, 0.45),
inset -8px -8px 18px rgba(0, 0, 0, 0.12),
inset 8px 10px 14px rgba(255, 255, 255, 0.4);
}
.roman {
position: absolute;
left: 50%;
top: 50%;
width: 20px;
height: 20px;
margin: -10px;
transform: rotate(calc(var(--i) * 30deg)) translateY(calc(var(--radius) * -1)) rotate(calc(var(--i) * -30deg));
font-family: "Times New Roman", serif;
font-size: clamp(0.76rem, 1.1vw, 0.95rem);
font-weight: 700;
color: #2a3447;
text-align: center;
user-select: none;
display: grid;
place-items: center;
}
.hand {
position: absolute;
left: 50%;
top: 50%;
transform-origin: 50% 100%;
transform: translate(-50%, -100%) rotate(0deg);
border-radius: 999px;
transition: transform 0.1s linear;
}
.hand.hour {
width: 6px;
height: 28%;
background: #1f2f46;
}
.hand.minute {
width: 4px;
height: 38%;
background: #2e4767;
}
.hand.second {
width: 2px;
height: 42%;
background: #b63b3b;
}
.pin {
position: absolute;
left: 50%;
top: 50%;
width: 12px;
height: 12px;
margin-left: -6px;
margin-top: -6px;
border-radius: 50%;
background: #111928;
border: 2px solid #f3f6ff;
z-index: 10;
}
.digital-subtime {
margin-top: auto;
margin-bottom: 3px;
font-family: "Consolas", monospace;
font-size: 0.9rem;
color: #d6deef;
letter-spacing: 0.08em;
}
@keyframes casioBlink {
0%, 20% {
opacity: 0;
}
20.01%, 60% {
opacity: 1;
}
60.01%, 100% {
opacity: 0;
}
}
@media (max-width: 900px) {
.row-2 {
grid-template-columns: repeat(2, 1fr);
}
.row-3 {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="clock-widget" id="clockWidget">
<div class="row row-1">
<div class="cell main-clock">
<div class="primary-zone" id="primaryZoneLabel"></div>
<div class="primary-time" aria-label="Primary time">
<span class="hh" id="primaryHH">00</span>
<span class="colon">:</span>
<span class="mm" id="primaryMM">00</span>
<span class="ss" id="primarySS">00</span>
</div>
</div>
</div>
<div class="row row-2">
<div class="cell placeholder"></div>
<div class="cell placeholder"></div>
<div class="cell placeholder"></div>
<div class="cell date-cell" id="dateCell">MON 01 JAN 2026</div>
</div>
<div class="row row-3" id="secondaryRow"></div>
</div>
<script>
(function () {
var CITY_LABELS = {
"America/New_York": "NEW YORK",
"Europe/Moscow": "MOSCOW",
"Asia/Shanghai": "BEIJING",
"Africa/Johannesburg": "CAPETOWN"
};
var ROMAN = ["XII", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI"];
var WEEK = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
var MONTH = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
function pad2(v) {
return String(v).padStart(2, "0");
}
function getSystemTimeZone() {
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
}
function computeSecondaryZones(primaryZone) {
var preferred = ["America/New_York", "Europe/Moscow", "Asia/Shanghai"];
var secondaries = preferred.filter(function (z) { return z !== primaryZone; });
while (secondaries.length < 3) {
if (!secondaries.includes("Africa/Johannesburg") && primaryZone !== "Africa/Johannesburg") {
secondaries.push("Africa/Johannesburg");
} else {
secondaries.push("UTC");
}
}
return secondaries.slice(0, 3);
}
function formatDatePrimary(date, zone) {
var parts = new Intl.DateTimeFormat("en-US", {
timeZone: zone,
weekday: "short",
day: "2-digit",
month: "short",
year: "numeric"
}).formatToParts(date);
var map = {};
parts.forEach(function (p) {
if (p.type !== "literal") map[p.type] = p.value;
});
var day = (map.weekday || "---").slice(0, 3).toUpperCase();
var dd = map.day || "00";
var mon = (map.month || "---").slice(0, 3).toUpperCase();
var yyyy = map.year || "0000";
return day + " " + dd + " " + mon + " " + yyyy;
}
function getTimeParts(date, zone) {
var parts = new Intl.DateTimeFormat("en-US", {
timeZone: zone,
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
}).formatToParts(date);
var map = {};
parts.forEach(function (p) {
if (p.type !== "literal") map[p.type] = p.value;
});
return {
hh: map.hour || "00",
mm: map.minute || "00",
ss: map.second || "00"
};
}
function buildSecondaryClock(tz) {
var cell = document.createElement("div");
cell.className = "cell analog-cell";
var label = document.createElement("div");
label.className = "tz-label";
label.textContent = CITY_LABELS[tz] || tz.toUpperCase();
cell.appendChild(label);
var clock = document.createElement("div");
clock.className = "wall-clock";
for (var i = 0; i < 12; i++) {
var numeral = document.createElement("div");
numeral.className = "roman";
numeral.style.setProperty("--i", String(i));
numeral.style.setProperty("--radius", "41%");
numeral.textContent = ROMAN[i];
clock.appendChild(numeral);
}
var hour = document.createElement("div");
hour.className = "hand hour";
var minute = document.createElement("div");
minute.className = "hand minute";
var second = document.createElement("div");
second.className = "hand second";
var pin = document.createElement("div");
pin.className = "pin";
clock.appendChild(hour);
clock.appendChild(minute);
clock.appendChild(second);
clock.appendChild(pin);
cell.appendChild(clock);
var digital = document.createElement("div");
digital.className = "digital-subtime";
digital.textContent = "--:--:--";
cell.appendChild(digital);
return {
tz: tz,
root: cell,
hour: hour,
minute: minute,
second: second,
digital: digital
};
}
var primaryZone = getSystemTimeZone();
var secondaryZones = computeSecondaryZones(primaryZone);
var secondaryRow = document.getElementById("secondaryRow");
var secondaryClocks = secondaryZones.map(function (tz) {
var c = buildSecondaryClock(tz);
secondaryRow.appendChild(c.root);
return c;
});
document.getElementById("primaryZoneLabel").textContent = "PRIMARY TZ: " + (CITY_LABELS[primaryZone] || primaryZone.toUpperCase());
function update() {
var now = new Date();
var primary = getTimeParts(now, primaryZone);
document.getElementById("primaryHH").textContent = primary.hh;
document.getElementById("primaryMM").textContent = primary.mm;
document.getElementById("primarySS").textContent = primary.ss;
document.getElementById("dateCell").textContent = formatDatePrimary(now, primaryZone);
secondaryClocks.forEach(function (clock) {
var p = getTimeParts(now, clock.tz);
var hNum = Number(p.hh);
var mNum = Number(p.mm);
var sNum = Number(p.ss);
var hourDeg = ((hNum % 12) + mNum / 60 + sNum / 3600) * 30;
var minuteDeg = (mNum + sNum / 60) * 6;
var secondDeg = sNum * 6;
clock.hour.style.transform = "translate(-50%, -100%) rotate(" + hourDeg + "deg)";
clock.minute.style.transform = "translate(-50%, -100%) rotate(" + minuteDeg + "deg)";
clock.second.style.transform = "translate(-50%, -100%) rotate(" + secondDeg + "deg)";
clock.digital.textContent = p.hh + ":" + p.mm + ":" + p.ss;
});
}
update();
setInterval(update, 1000);
})();
</script>
</body>
</html>

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -11,12 +11,34 @@
})();
</script>
<link rel="stylesheet" href="/app/androidcast_project/crashes/assets/css/app.css">
<link rel="stylesheet" href="hub.css">
<link rel="stylesheet" href="hub.css?v=20260527t">
<link rel="stylesheet" href="hub-logo-layers.css">
</head>
<body class="hub-page">
<div id="overtime-alphabet-host" hidden aria-hidden="true"></div>
<div id="hub-logo-stage" class="hub-logo-stage" hidden aria-hidden="true"></div>
<button id="hub-compass-hit" class="hub-compass-hit" type="button" aria-label="Open deployment model"></button>
<section class="hub-clock-widget" id="hub-clock-widget" aria-label="World clock widget">
<div class="cw-row cw-row-main">
<div class="cw-cell cw-main-cell">
<div class="cw-line cw-line-time">
<strong class="cw-time-main">
<span id="cw-hm-lcd" class="ot-lcd-line ot-lcd-bold" aria-label="Hours and minutes"></span>
<span id="cw-ss-lcd" class="ot-lcd-line ot-lcd-sec ot-lcd-bold" aria-label="Seconds"></span>
<span id="cw-tz-text" class="ot-lcd-line ot-lcd-tz ot-lcd-bold" aria-label="Timezone offset"></span>
</strong>
</div>
<div class="cw-line cw-line-region" id="cw-region">EUROPE/WARSAW</div>
<div class="cw-line cw-line-date" id="cw-date-line">WED 27 MAY 2026</div>
</div>
</div>
<div class="cw-row cw-row-mid">
<div class="cw-cell"></div>
<div class="cw-cell"></div>
<div class="cw-cell"></div>
<div class="cw-cell"></div>
</div>
<div class="cw-row cw-row-world" id="cw-world-row"></div>
</section>
<div id="hub-docs-modal" class="hub-docs-modal" hidden aria-hidden="true">
<div class="hub-docs-backdrop" data-close-docs></div>
<section class="hub-docs-panel card card--lift" role="dialog" aria-modal="true" aria-labelledby="hub-docs-title">
@@ -24,8 +46,11 @@
<h2 id="hub-docs-title">Deployment model</h2>
<button type="button" class="hub-docs-close" data-close-docs aria-label="Close">x</button>
</header>
<div id="hub-docs-body" class="hub-docs-body">
<p class="muted">Loading...</p>
<div class="hub-docs-layout">
<aside class="hub-docs-toc detail-tree" id="hub-docs-toc" aria-label="Table of contents">
<p class="muted hub-docs-toc-title">Table of contents</p>
</aside>
<div class="hub-docs-panels" id="hub-docs-panels"></div>
</div>
</section>
</div>
@@ -35,7 +60,6 @@
<div class="hub-brand">
<img class="hub-logo-mark" src="assets/hub-logo-transparent.svg" alt="" width="112" height="84" decoding="async">
<h1 class="hub-title">Android Cast</h1>
<img class="hub-thunder" src="assets/hub-thunder.svg" alt="Thunder underline" width="312" height="46" decoding="async">
</div>
<p class="muted hub-subtitle">Project hub</p>
<label class="toolbar-select hub-theme">
@@ -90,17 +114,18 @@
var supportsLayout = !!(window.CSS && CSS.supports && CSS.supports('object-fit', 'cover'));
if (!supportsSvg || !supportsLayout) return;
var logoBuild = '20260527j';
var logoBuild = '20260527s';
var fragments = {
space: 'assets/hub-logo-fragment-space.svg',
globe: 'assets/hub-logo-fragment-globe.svg',
continents: 'assets/hub-logo-continents-mercator-globe.svg',
gridOverlay: 'assets/hub-logo-fragment-globe-grid-overlay.svg'
gridOverlay: 'assets/hub-logo-fragment-globe-grid-overlay.svg',
compass: 'assets/hub-logo-fragment-compass.svg'
};
var parts = {
full: ['space', 'globe', 'continents', 'gridOverlay'],
full: ['space', 'globe', 'continents', 'gridOverlay', 'compass'],
space: ['space'],
globe: ['globe', 'continents', 'gridOverlay']
globe: ['globe', 'continents', 'gridOverlay', 'compass']
};
var params = new URLSearchParams(window.location.search);
@@ -123,6 +148,12 @@
img.alt = '';
img.decoding = 'async';
img.loading = 'lazy';
if (name === 'compass') {
img.id = 'hub-compass-layer';
img.setAttribute('role', 'button');
img.setAttribute('tabindex', '0');
img.setAttribute('aria-label', 'Open deployment model');
}
stage.appendChild(img);
});
@@ -135,9 +166,14 @@
stage.setAttribute('aria-hidden', 'true');
var docsModal = document.getElementById('hub-docs-modal');
var docsBody = document.getElementById('hub-docs-body');
var compassHit = document.getElementById('hub-compass-hit');
if (!docsModal || !docsBody || !compassHit) return;
var docsToc = document.getElementById('hub-docs-toc');
var docsPanels = document.getElementById('hub-docs-panels');
var compassLayer = document.getElementById('hub-compass-layer');
var docsApi = '/app/androidcast_project/crashes/api/hub_deploy_docs.php';
var docsQueue = [];
var docsLoading = false;
var docsCache = {};
var docsPanelsById = {};
function closeDocs() {
docsModal.hidden = true;
@@ -149,42 +185,540 @@
docsModal.setAttribute('aria-hidden', 'false');
}
function loadDocs(section) {
var url = '/app/androidcast_project/crashes/api/hub_deploy_docs.php';
if (section) url += '?section=' + encodeURIComponent(section);
docsBody.innerHTML = '<p class="muted">Loading...</p>';
openDocs();
fetch(url, { headers: { 'Accept': 'application/json' } })
.then(function (res) { return res.json(); })
.then(function (payload) {
if (!payload || !payload.ok) {
docsBody.innerHTML = '<p class="muted">Failed to load deployment model.</p>';
return;
}
docsBody.innerHTML = payload.html || '<p class="muted">No content.</p>';
})
.catch(function () {
docsBody.innerHTML = '<p class="muted">Network error while loading deployment model.</p>';
});
function xhrJson(url, onOk, onErr) {
var xhr = new XMLHttpRequest();
xhr.open('GET', url, true);
xhr.setRequestHeader('Accept', 'application/json');
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) return;
if (xhr.status < 200 || xhr.status >= 300) {
if (onErr) onErr();
return;
}
try {
onOk(JSON.parse(xhr.responseText));
} catch (e) {
if (onErr) onErr();
}
};
xhr.send();
}
compassHit.addEventListener('click', function () { loadDocs('intro'); });
function ensureDocsPanel(sectionId, title) {
if (docsPanelsById[sectionId]) return docsPanelsById[sectionId];
var item = document.createElement('div');
item.className = 'detail-tree-item hub-docs-section';
item.dataset.section = sectionId;
var panelId = 'hub-doc-panel-' + sectionId;
item.innerHTML =
'<div class="detail-tree-row report-row" role="button" tabindex="0" data-tree-toggle data-controls="' + panelId + '">' +
'<span class="report-tree-cell">' +
'<button type="button" class="report-tree-toggle" aria-expanded="false" aria-controls="' + panelId + '" tabindex="-1">' +
'<span class="report-tree-arrow" aria-hidden="true"></span>' +
'</button>' +
'</span>' +
'<span class="detail-tree-caption">' + title + '</span>' +
'</div>' +
'<div class="detail-tree-body" id="' + panelId + '" hidden><p class="muted">Loading...</p></div>';
docsPanels.appendChild(item);
docsPanelsById[sectionId] = item;
bindDocsTree(item);
return item;
}
function bindDocsTree(root) {
root.querySelectorAll('[data-tree-toggle]').forEach(function (row) {
var panelId = row.getAttribute('data-controls');
var panel = panelId ? document.getElementById(panelId) : null;
var btn = row.querySelector('.report-tree-toggle');
if (!panel) return;
var toggle = function () {
var open = panel.hidden;
panel.hidden = !open;
if (btn) btn.setAttribute('aria-expanded', open ? 'true' : 'false');
row.classList.toggle('report-row--open', open);
};
if (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
toggle();
});
}
row.addEventListener('click', toggle);
row.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
});
});
}
function renderSectionHtml(sectionId, html) {
var item = docsPanelsById[sectionId];
if (!item) return;
var body = item.querySelector('.detail-tree-body');
var row = item.querySelector('.detail-tree-row');
var btn = item.querySelector('.report-tree-toggle');
if (body) body.innerHTML = html || '<p class="muted">No content.</p>';
if (body && row && body.hidden) {
body.hidden = false;
row.classList.add('report-row--open');
if (btn) btn.setAttribute('aria-expanded', 'true');
}
}
function pumpDocsQueue() {
if (docsLoading || docsQueue.length === 0) return;
docsLoading = true;
var sectionId = docsQueue.shift();
if (docsCache[sectionId]) {
renderSectionHtml(sectionId, docsCache[sectionId]);
docsLoading = false;
pumpDocsQueue();
return;
}
xhrJson(docsApi + '?section=' + encodeURIComponent(sectionId), function (payload) {
if (payload && payload.ok) {
docsCache[sectionId] = payload.html || '';
renderSectionHtml(sectionId, docsCache[sectionId]);
} else {
renderSectionHtml(sectionId, '<p class="muted">Failed to load section.</p>');
}
docsLoading = false;
pumpDocsQueue();
}, function () {
renderSectionHtml(sectionId, '<p class="muted">Network error while loading section.</p>');
docsLoading = false;
pumpDocsQueue();
});
}
function queueSection(sectionId, front) {
if (!sectionId) return;
if (front) docsQueue.unshift(sectionId);
else docsQueue.push(sectionId);
pumpDocsQueue();
}
function buildTocNode(node, parentEl) {
var children = node.children || [];
if (children.length) {
children.forEach(function (child) {
ensureDocsPanel(child.id, child.title);
queueSection(child.id, false);
});
return;
}
ensureDocsPanel(node.id, node.title);
queueSection(node.id, false);
}
function openDeploymentModel(prioritySection) {
openDocs();
docsPanels.innerHTML = '';
docsPanelsById = {};
docsCache = {};
docsQueue = [];
docsLoading = false;
docsToc.innerHTML = '<p class="muted hub-docs-toc-title">Table of contents</p>';
xhrJson(docsApi + '?action=toc', function (payload) {
if (!payload || !payload.ok || !payload.sections) {
docsPanels.innerHTML = '<p class="muted">Failed to load table of contents.</p>';
return;
}
payload.sections.forEach(function (node) {
var group = document.createElement('div');
group.className = 'hub-docs-toc-group';
var head = document.createElement('button');
head.type = 'button';
head.className = 'hub-docs-toc-head';
head.textContent = node.title;
head.dataset.section = node.id;
head.addEventListener('click', function () {
var kids = node.children || [];
if (kids.length) queueSection(kids[0].id, true);
else queueSection(node.id, true);
});
group.appendChild(head);
(node.children || []).forEach(function (child) {
var leaf = document.createElement('button');
leaf.type = 'button';
leaf.className = 'hub-docs-toc-leaf';
leaf.textContent = child.title;
leaf.dataset.section = child.id;
leaf.addEventListener('click', function () { queueSection(child.id, true); });
group.appendChild(leaf);
});
docsToc.appendChild(group);
buildTocNode(node, docsPanels);
});
if (prioritySection) queueSection(prioritySection, true);
else if (payload.default_section) queueSection(payload.default_section, true);
}, function () {
docsPanels.innerHTML = '<p class="muted">Network error while loading deployment model.</p>';
});
}
function bindCompassOpen() {
if (!compassLayer) return;
var open = function () { openDeploymentModel(); };
compassLayer.addEventListener('click', open);
compassLayer.addEventListener('keydown', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
open();
}
});
}
bindCompassOpen();
docsModal.addEventListener('click', function (ev) {
var target = ev.target;
if (!(target instanceof Element)) return;
if (target.hasAttribute('data-close-docs')) {
closeDocs();
return;
}
var link = target.closest('[data-doc-link]');
if (link) {
ev.preventDefault();
loadDocs(link.getAttribute('data-doc-link') || 'intro');
}
if (target.hasAttribute('data-close-docs')) closeDocs();
});
document.addEventListener('keydown', function (ev) {
if (ev.key === 'Escape' && !docsModal.hidden) closeDocs();
});
var cityLabels = {
'America/New_York': 'NEW YORK',
'Europe/Moscow': 'MOSCOW',
'Asia/Shanghai': 'BEIJING',
'Africa/Johannesburg': 'CAPETOWN'
};
var roman = ['XII', 'I', 'II', 'III', 'IV', 'V', 'VI', 'VII', 'VIII', 'IX', 'X', 'XI'];
function pad2(v) { return String(v).padStart(2, '0'); }
function systemZone() { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; }
function secondaryZones(primary) {
var preferred = ['America/New_York', 'Europe/Moscow', 'Asia/Shanghai'];
var list = preferred.filter(function (z) { return z !== primary; });
while (list.length < 3) {
if (!list.includes('Africa/Johannesburg') && primary !== 'Africa/Johannesburg') list.push('Africa/Johannesburg');
else list.push('UTC');
}
return list.slice(0, 3);
}
function timeParts(date, zone) {
var parts = new Intl.DateTimeFormat('en-US', {
timeZone: zone, hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit'
}).formatToParts(date);
var map = {};
parts.forEach(function (p) { if (p.type !== 'literal') map[p.type] = p.value; });
return { hh: map.hour || '00', mm: map.minute || '00', ss: map.second || '00' };
}
function formatDate(date, zone) {
var parts = new Intl.DateTimeFormat('en-US', {
timeZone: zone, weekday: 'short', day: '2-digit', month: 'short', year: 'numeric'
}).formatToParts(date);
var map = {};
parts.forEach(function (p) { if (p.type !== 'literal') map[p.type] = p.value; });
return (map.weekday || '---').slice(0, 3).toUpperCase() + ' '
+ (map.day || '00') + ' '
+ (map.month || '---').slice(0, 3).toUpperCase() + ' '
+ (map.year || '0000');
}
function buildWorldClock(tz) {
var cell = document.createElement('div');
cell.className = 'cw-cell cw-world-cell';
var label = document.createElement('div');
label.className = 'cw-world-label';
label.textContent = cityLabels[tz] || tz.toUpperCase();
cell.appendChild(label);
var dial = document.createElement('div');
dial.className = 'cw-dial';
for (var i = 0; i < 12; i++) {
var n = document.createElement('div');
n.className = 'cw-roman';
if (i === 0 || i === 3 || i === 6 || i === 9) n.className += ' cw-roman-major';
else n.className += ' cw-roman-minor';
n.style.setProperty('--i', String(i));
n.textContent = roman[i];
dial.appendChild(n);
}
var hour = document.createElement('div');
hour.className = 'cw-hand cw-hour';
var minute = document.createElement('div');
minute.className = 'cw-hand cw-minute';
var second = document.createElement('div');
second.className = 'cw-hand cw-second';
var pin = document.createElement('div');
pin.className = 'cw-pin';
dial.appendChild(hour);
dial.appendChild(minute);
dial.appendChild(second);
dial.appendChild(pin);
cell.appendChild(dial);
return { tz: tz, hour: hour, minute: minute, second: second, root: cell };
}
var overtimeReady = false;
var overtimeSymbolIds = {};
function overtimeSymbolId(ch) {
if (ch >= '0' && ch <= '9') return 'ot-' + ch;
if (ch === ':') return 'ot-colon';
if (ch === '+') return 'ot-plus';
if (ch === '-') return 'ot-minus';
if (ch === ' ') return 'ot-space';
if (ch >= 'A' && ch <= 'Z') return 'ot-' + ch;
return null;
}
function loadOvertimeAlphabet(done) {
var host = document.getElementById('overtime-alphabet-host');
if (!host) { done(false); return; }
fetch('assets/overtime-01-alphabet.svg?v=' + logoBuild)
.then(function (res) { return res.text(); })
.then(function (svgText) {
host.innerHTML = svgText;
host.querySelectorAll('symbol').forEach(function (sym) {
if (sym.id) overtimeSymbolIds[sym.id] = sym;
});
overtimeReady = true;
done(true);
})
.catch(function () { done(false); });
}
function buildGlyphGrid(svg, vb) {
var seg = document.createElementNS('http://www.w3.org/2000/svg', 'g');
seg.setAttribute('class', 'ot-grid');
var w = vb.width || 20;
var h = vb.height || 32;
var hPad = Math.max(1, w * 0.12);
var topY = Math.max(1, h * 0.04);
var midY = h * 0.47;
var botY = h * 0.86;
var segW = Math.max(6, w - hPad * 2);
var segH = Math.max(2, h * 0.09);
var vW = Math.max(2, w * 0.14);
var vH = Math.max(6, h * 0.33);
var leftX = Math.max(1, hPad * 0.45);
var rightX = Math.max(leftX + vW + 1, w - leftX - vW);
var upperY = topY + segH;
var lowerY = midY + segH * 0.6;
var defs = [
[hPad, topY, segW, segH],
[leftX, upperY, vW, vH],
[rightX, upperY, vW, vH],
[hPad, midY, segW, segH],
[leftX, lowerY, vW, vH],
[rightX, lowerY, vW, vH],
[hPad, botY, segW, segH]
];
defs.forEach(function (d) {
var r = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
r.setAttribute('x', d[0].toFixed(2));
r.setAttribute('y', d[1].toFixed(2));
r.setAttribute('width', d[2].toFixed(2));
r.setAttribute('height', d[3].toFixed(2));
r.setAttribute('rx', '1');
seg.appendChild(r);
});
if (w >= 22) {
var d1 = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
d1.setAttribute('x', (w * 0.34).toFixed(2));
d1.setAttribute('y', (h * 0.15).toFixed(2));
d1.setAttribute('width', Math.max(2, w * 0.10).toFixed(2));
d1.setAttribute('height', Math.max(6, h * 0.34).toFixed(2));
d1.setAttribute('rx', '1');
d1.setAttribute('transform', 'rotate(22 ' + (w * 0.39).toFixed(2) + ' ' + (h * 0.32).toFixed(2) + ')');
seg.appendChild(d1);
var d2 = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
d2.setAttribute('x', (w * 0.56).toFixed(2));
d2.setAttribute('y', (h * 0.15).toFixed(2));
d2.setAttribute('width', Math.max(2, w * 0.10).toFixed(2));
d2.setAttribute('height', Math.max(6, h * 0.34).toFixed(2));
d2.setAttribute('rx', '1');
d2.setAttribute('transform', 'rotate(-22 ' + (w * 0.61).toFixed(2) + ' ' + (h * 0.32).toFixed(2) + ')');
seg.appendChild(d2);
}
svg.appendChild(seg);
}
function renderOvertimeText(el, text, opts) {
opts = opts || {};
if (!el) return;
el.innerHTML = '';
if (!overtimeReady) {
el.textContent = text;
return;
}
String(text).toUpperCase().split('').forEach(function (ch) {
var sid = overtimeSymbolId(ch);
if (!sid || !overtimeSymbolIds[sid]) return;
var wrap = document.createElement('span');
wrap.className = 'ot-glyph-wrap' + (opts.bold ? ' ot-glyph-wrap--bold' : '');
if (opts.blinkColon && ch === ':') wrap.className += ' ot-colon-blink';
var sym = overtimeSymbolIds[sid];
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
var viewBox = sym.getAttribute('viewBox') || '0 0 20 32';
svg.setAttribute('viewBox', viewBox);
svg.setAttribute('class', 'ot-glyph');
var vb = viewBox.split(/\s+/).map(function (v) { return Number(v) || 0; });
buildGlyphGrid(svg, { width: vb[2] || 20, height: vb[3] || 32 });
var use = document.createElementNS('http://www.w3.org/2000/svg', 'use');
use.setAttribute('href', '#' + sid);
use.setAttribute('class', 'ot-active');
svg.appendChild(use);
wrap.appendChild(svg);
el.appendChild(wrap);
});
}
function renderOvertimeDigits(el, text, blinkColon, bold) {
renderOvertimeText(el, text, { blinkColon: blinkColon, bold: bold });
}
function regionLabel(tz) {
var parts = tz.split('/');
if (parts.length >= 2) {
return parts[0].replace(/_/g, ' ').toUpperCase() + '/' + parts[parts.length - 1].replace(/_/g, ' ').toUpperCase();
}
return tz.toUpperCase();
}
function gmtOffsetLabel(date, tz) {
try {
var parts = new Intl.DateTimeFormat('en-US', { timeZone: tz, timeZoneName: 'shortOffset' }).formatToParts(date);
var name = parts.find(function (p) { return p.type === 'timeZoneName'; });
if (name && name.value) {
return name.value.replace('GMT', 'GMT').replace('UTC', 'GMT').toUpperCase();
}
} catch (e) {}
return 'GMT+0';
}
function formatDateLine(date, zone) {
var parts = new Intl.DateTimeFormat('en-US', {
timeZone: zone, weekday: 'short', day: '2-digit', month: 'short', year: 'numeric'
}).formatToParts(date);
var map = {};
parts.forEach(function (p) { if (p.type !== 'literal') map[p.type] = p.value; });
return (map.weekday || '---').slice(0, 3).toUpperCase() + ' '
+ String(Number(map.day || '0')).padStart(2, '0') + ' '
+ (map.month || '---').slice(0, 3).toUpperCase() + ' '
+ (map.year || '0000');
}
var primaryTz = systemZone();
var worldRow = document.getElementById('cw-world-row');
var secondary = secondaryZones(primaryTz).map(function (tz) {
var model = buildWorldClock(tz);
worldRow.appendChild(model.root);
return model;
});
function updateClockWidget() {
var now = new Date();
var p = timeParts(now, primaryTz);
renderOvertimeDigits(document.getElementById('cw-hm-lcd'), p.hh + ':' + p.mm, false, true);
renderOvertimeDigits(document.getElementById('cw-ss-lcd'), ':' + p.ss, true, true);
renderOvertimeText(document.getElementById('cw-tz-text'), gmtOffsetLabel(now, primaryTz), { bold: true });
document.getElementById('cw-region').textContent = regionLabel(primaryTz);
document.getElementById('cw-date-line').textContent = formatDateLine(now, primaryTz);
secondary.forEach(function (clock) {
var t = timeParts(now, clock.tz);
var h = Number(t.hh), m = Number(t.mm), s = Number(t.ss);
var hDeg = ((h % 12) + (m / 60) + (s / 3600)) * 30;
var mDeg = (m + (s / 60)) * 6;
var sDeg = s * 6;
clock.hour.style.transform = 'translate(-50%, -100%) rotate(' + hDeg + 'deg)';
clock.minute.style.transform = 'translate(-50%, -100%) rotate(' + mDeg + 'deg)';
clock.second.style.transform = 'translate(-50%, -100%) rotate(' + sDeg + 'deg)';
});
}
(function makeClockWidgetInteractive() {
var widget = document.getElementById('hub-clock-widget');
if (!widget) return;
var arrowPath = 'M44.106,42.943H3.987L44.933,1.995c0.456-0.455,0.456-1.196,0-1.651c-0.452-0.455-1.195-0.455-1.651,0L2.336,41.292V1.168C2.336,0.524,1.81,0,1.168,0C0.523,0,0,0.524,0,1.168v44.111h44.111c0.644,0,1.168-0.522,1.168-1.168C45.279,43.469,44.755,42.943,44.106,42.943z';
var handles = ['nw', 'ne', 'sw', 'se'];
handles.forEach(function (pos) {
var h = document.createElement('span');
h.className = 'cw-resize-handle cw-resize-' + pos;
h.dataset.resize = pos;
h.setAttribute('aria-hidden', 'true');
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 45.279 45.279');
var path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', arrowPath);
svg.appendChild(path);
h.appendChild(svg);
widget.appendChild(h);
});
var drag = null;
widget.addEventListener('pointerdown', function (ev) {
var target = ev.target;
if (!(target instanceof Element)) return;
var box = widget.getBoundingClientRect();
var handle = target.closest('[data-resize]');
var resizeDir = handle ? handle.getAttribute('data-resize') : null;
if (resizeDir) {
drag = {
mode: 'resize',
dir: resizeDir,
x: ev.clientX, y: ev.clientY,
left: box.left, top: box.top, width: box.width, height: box.height
};
} else {
drag = { mode: 'move', dx: ev.clientX - box.left, dy: ev.clientY - box.top };
}
widget.setPointerCapture(ev.pointerId);
ev.preventDefault();
});
widget.addEventListener('pointermove', function (ev) {
if (!drag) return;
if (drag.mode === 'move') {
widget.style.left = Math.max(0, ev.clientX - drag.dx) + 'px';
widget.style.top = Math.max(0, ev.clientY - drag.dy) + 'px';
} else {
var minW = 280, minH = 210;
var dx = ev.clientX - drag.x;
var dy = ev.clientY - drag.y;
var left = drag.left, top = drag.top, width = drag.width, height = drag.height;
if (drag.dir.indexOf('e') !== -1) width = Math.max(minW, drag.width + dx);
if (drag.dir.indexOf('s') !== -1) height = Math.max(minH, drag.height + dy);
if (drag.dir.indexOf('w') !== -1) {
width = Math.max(minW, drag.width - dx);
left = drag.left + (drag.width - width);
}
if (drag.dir.indexOf('n') !== -1) {
height = Math.max(minH, drag.height - dy);
top = drag.top + (drag.height - height);
}
widget.style.left = Math.max(0, left) + 'px';
widget.style.top = Math.max(0, top) + 'px';
widget.style.width = width + 'px';
widget.style.minWidth = width + 'px';
widget.style.transform = 'none';
}
});
widget.addEventListener('pointerup', function (ev) {
if (!drag) return;
drag = null;
widget.releasePointerCapture(ev.pointerId);
});
})();
loadOvertimeAlphabet(function () {
updateClockWidget();
setInterval(updateClockWidget, 1000);
});
})();
</script>
</body>

View File

@@ -4,93 +4,143 @@ require_once __DIR__ . '/../../src/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
$section = strtolower(trim((string)($_GET['section'] ?? 'intro')));
if ($section === '') $section = 'intro';
$action = strtolower(trim((string)($_GET['action'] ?? '')));
$section = strtolower(trim((string)($_GET['section'] ?? '')));
function hub_docs_nav(): string {
return '<nav><strong>(2) TABLE OF CONTENTS</strong><ul>'
. '<li><a data-doc-link="intro" href="?section=intro">(1) INTRODUCTION</a></li>'
. '<li><a data-doc-link="deploy" href="?section=deploy">(3) LOCAL SINGLE-STEP TEST DEPLOYMENT</a></li>'
. '<li><a data-doc-link="orchestration" href="?section=orchestration">(4) ORCHESTRATION MODEL</a></li>'
. '<li><a data-doc-link="routing" href="?section=routing">(5) XHR SUPERVISED VIEW MODEL</a></li>'
. '<li><a data-doc-link="ops" href="?section=ops">(6) OPERATIONS CHECKLIST</a></li>'
. '</ul></nav>';
function hub_docs_toc(): array
{
return [
[
'id' => 'intro',
'title' => 'INTRODUCTION',
'children' => [
['id' => 'intro-authors', 'title' => 'Authors'],
['id' => 'intro-revision', 'title' => 'Revision details'],
],
],
[
'id' => 'deploy',
'title' => 'LOCAL SINGLE-STEP TEST DEPLOYMENT',
'children' => [
['id' => 'deploy-prep', 'title' => 'Preparations, environment'],
['id' => 'deploy-packages', 'title' => 'Package prerequisites by distro'],
['id' => 'deploy-checks', 'title' => 'Local checks'],
],
],
['id' => 'orchestration', 'title' => 'ORCHESTRATION MODEL', 'children' => []],
['id' => 'routing', 'title' => 'XHR SUPERVISED VIEW MODEL', 'children' => []],
[
'id' => 'ops',
'title' => 'OPERATIONS CHECKLIST',
'children' => [],
],
];
}
function hub_docs_intro(): string {
return '<h3>(1) INTRODUCTION</h3>'
. '<h4>(1.1) authors</h4>'
. '<p>Project owner: Anton Afanaasyeu. Implementation assistant: Cursor coding agent.</p>'
. '<h4>(1.2) revision details</h4>'
. '<p>Revision scope: landing compass interaction, XHR-driven deployment docs view, Docker orchestration model integration for next branch.</p>'
. hub_docs_nav();
function hub_docs_section_html(string $section): string
{
switch ($section) {
case 'intro-authors':
return '<p>Project owner: Anton Afanaasyeu. Implementation assistant: Cursor coding agent.</p>';
case 'intro-revision':
return '<p>Revision scope: landing compass interaction, XHR-driven deployment docs view, Docker orchestration model integration for next branch.</p>';
case 'deploy-prep':
return '<p>Prerequisites: docker + docker compose plugin, git checkout of android cast on branch <code>next</code>.</p>'
. '<p>Run one command from repo: <code>cd orchestration && ./deploy.sh</code></p>';
case 'deploy-packages':
return '<table><thead><tr><th>Distro</th><th>Install steps</th></tr></thead><tbody>'
. '<tr><td>Alpine</td><td><code>apk add docker docker-cli-compose bash git curl</code><br><code>rc-update add docker default && service docker start</code></td></tr>'
. '<tr><td>Debian / Ubuntu</td><td><code>apt update && apt install -y docker.io docker-compose-plugin git curl</code><br><code>systemctl enable --now docker</code></td></tr>'
. '<tr><td>Fedora</td><td><code>dnf install -y docker docker-compose-plugin git curl</code><br><code>systemctl enable --now docker</code></td></tr>'
. '<tr><td>Arch</td><td><code>pacman -S --noconfirm docker docker-compose git curl</code><br><code>systemctl enable --now docker</code></td></tr>'
. '</tbody></table>';
case 'deploy-checks':
return '<ul>'
. '<li><code>http://localhost:8080/app/androidcast_project/</code> (hub)</li>'
. '<li><code>http://localhost:8080/app/androidcast_project/crashes/</code> (crashes/tasks)</li>'
. '<li><code>http://localhost:8080/app/androidcast_project/git/</code> (redirect to gitea FE)</li>'
. '</ul>';
case 'orchestration':
return '<p>All infra runs in docker with shared source bind-mount where possible.</p>'
. '<ul>'
. '<li>landing FE (nginx): serves hub and routes subpaths</li>'
. '<li>gitea FE (nginx) + gitea app</li>'
. '<li>crashes FE (nginx) + php-fpm + mariadb</li>'
. '<li>single command create/update: <code>orchestration/deploy.sh</code></li>'
. '</ul>'
. '<p>Base images: nginx, php-fpm, mariadb. This stays minimal and reproducible on laptop devices.</p>';
case 'routing':
return '<p>The Compass click opens a modal. Content is requested via XMLHttpRequest/fetch from BE endpoint '
. '<code>/app/androidcast_project/crashes/api/hub_deploy_docs.php</code>.</p>'
. '<p>Internal links in this modal use tree-list captions and are intercepted on frontend, then re-requested through BE. '
. 'Browser URL bar is not changed.</p>'
. '<p>Static assets remain at original paths and are not rewritten in this view.</p>';
case 'ops':
return '<ol>'
. '<li>Pull latest <code>next</code></li>'
. '<li>Run <code>orchestration/deploy.sh</code></li>'
. '<li>Verify hub/crashes/gitea endpoints</li>'
. '<li>For app changes: edit source in repo, rerun deploy script</li>'
. '</ol>';
case 'intro':
return '<p>Select a topic in the table of contents to load section content from backend.</p>';
default:
return '<p class="muted">Section not found.</p>';
}
}
function hub_docs_deploy(): string {
return '<h3>(3) LOCAL SINGLE-STEP TEST DEPLOYMENT</h3>'
. '<h4>(3.1) preparations, environment</h4>'
. '<p>Prerequisites: docker + docker compose plugin, git checkout of android cast on branch <code>next</code>.</p>'
. '<p>Run one command from repo: <code>cd orchestration && ./deploy.sh</code></p>'
. '<h4>(3.2) package prerequisites by distro</h4>'
. '<table><thead><tr><th>Distro</th><th>Install steps</th></tr></thead><tbody>'
. '<tr><td>Alpine</td><td><code>apk add docker docker-cli-compose bash git curl</code><br><code>rc-update add docker default && service docker start</code></td></tr>'
. '<tr><td>Debian / Ubuntu</td><td><code>apt update && apt install -y docker.io docker-compose-plugin git curl</code><br><code>systemctl enable --now docker</code></td></tr>'
. '<tr><td>Fedora</td><td><code>dnf install -y docker docker-compose-plugin git curl</code><br><code>systemctl enable --now docker</code></td></tr>'
. '<tr><td>Arch</td><td><code>pacman -S --noconfirm docker docker-compose git curl</code><br><code>systemctl enable --now docker</code></td></tr>'
. '</tbody></table>'
. '<h4>(3.3) local checks</h4>'
. '<ul><li><code>http://localhost:8080/app/androidcast_project/</code> (hub)</li>'
. '<li><code>http://localhost:8080/app/androidcast_project/crashes/</code> (crashes/tasks)</li>'
. '<li><code>http://localhost:8080/app/androidcast_project/git/</code> (redirect to gitea FE)</li></ul>'
. hub_docs_nav();
function hub_docs_section_title(string $section): string
{
foreach (hub_docs_toc() as $node) {
if ($node['id'] === $section) {
return (string)$node['title'];
}
foreach ($node['children'] as $child) {
if ($child['id'] === $section) {
return (string)$child['title'];
}
}
}
return 'Section';
}
function hub_docs_orchestration(): string {
return '<h3>(4) ORCHESTRATION MODEL</h3>'
. '<p>All infra runs in docker with shared source bind-mount where possible.</p>'
. '<ul>'
. '<li>landing FE (nginx): serves hub and routes subpaths</li>'
. '<li>gitea FE (nginx) + gitea app</li>'
. '<li>crashes FE (nginx) + php-fpm + mariadb</li>'
. '<li>single command create/update: <code>orchestration/deploy.sh</code></li>'
. '</ul>'
. '<p>Base images: nginx, php-fpm, mariadb. This stays minimal and reproducible on laptop devices.</p>'
. hub_docs_nav();
function hub_docs_all_section_ids(): array
{
$ids = [];
foreach (hub_docs_toc() as $node) {
if (!empty($node['children'])) {
foreach ($node['children'] as $child) {
$ids[] = (string)$child['id'];
}
} else {
$ids[] = (string)$node['id'];
}
}
return $ids;
}
function hub_docs_routing(): string {
return '<h3>(5) XHR SUPERVISED VIEW MODEL</h3>'
. '<p>The Compass click opens a modal. Content is requested via XMLHttpRequest/fetch from BE endpoint '
. '<code>/app/androidcast_project/crashes/api/hub_deploy_docs.php</code>.</p>'
. '<p>Internal links in this modal use <code>data-doc-link</code> and are intercepted on frontend, then re-requested through BE. '
. 'Browser URL bar is not changed.</p>'
. '<p>Static assets remain at original paths and are not rewritten in this view.</p>'
. hub_docs_nav();
if ($action === 'toc') {
echo safe_json_encode([
'ok' => true,
'sections' => hub_docs_toc(),
'default_section' => 'intro-authors',
'preload' => hub_docs_all_section_ids(),
]);
exit;
}
function hub_docs_ops(): string {
return '<h3>(6) OPERATIONS CHECKLIST</h3>'
. '<ol>'
. '<li>Pull latest <code>next</code></li>'
. '<li>Run <code>orchestration/deploy.sh</code></li>'
. '<li>Verify hub/crashes/gitea endpoints</li>'
. '<li>For app changes: edit source in repo, rerun deploy script</li>'
. '</ol>'
. hub_docs_nav();
if ($section === '') {
$section = 'intro-authors';
}
$map = [
'intro' => 'hub_docs_intro',
'deploy' => 'hub_docs_deploy',
'orchestration' => 'hub_docs_orchestration',
'routing' => 'hub_docs_routing',
'ops' => 'hub_docs_ops',
];
if (!isset($map[$section])) $section = 'intro';
$known = hub_docs_all_section_ids();
if (!in_array($section, $known, true)) {
$section = 'intro-authors';
}
echo safe_json_encode([
'ok' => true,
'section' => $section,
'html' => $map[$section](),
'title' => hub_docs_section_title($section),
'html' => hub_docs_section_html($section),
]);