sundial
[github]jaipur's samrat yantra, its shadow set by your local time
objects 65×23 10 fps 9.0 kB reads the clock
· ·· · · · · · ▗█▖ · + + │ │ · /=\ · /=\ + + · + /=\ + ·+ /=\ ______ /=\ ______ |::::: 6 /=\ 6 :::::| |······ /=\ ······| |····:: 7 /===\ 5 ::····| |\::::·· /===\ ··::::/| | \······ 8 /===\ 4 ······/ | | \.·::::: 9 /===\ 3 :::::·./ | | '.:·····: /|=|\ :·····:.' | | ''.··:::·· 10 11 /|=|\ 1 2 ··:::··.'' | | '-.:·····::· /|=|\ ·::·····:.-' | | ''..··::····::··/|=|\··::····::··..'' | | ''-..····:··/|===|\··:····..-'' | | ╭─╮ ╭─╮ '''-..__/|===|\__..-''' ╭─╮ ╭─╮ | | │ │ │ │ /|===|\ │ │ │ │ | ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ · ·· · · · · · ▗█▖ · + + │ │ · /=\ · /=\ + + · + /=\ + ·+ /=\ ______ /=\ ______ |::::: 6 /=\ 6 :::::| |······ /=\ ······| |····:: 7 /===\ 5 ::····| |\::::·· /===\ ··::::/| | \······ 8 /===\ 4 ······/ | | \.·::::: 9 /===\ 3 :::::·./ | | '.:·····: /|=|\ :·····:.' | | ''.··:::·· 10 11 /|=|\ 1 2 ··:::··.'' | | '-.:·····::· /|=|\ ·::·····:.-' | | ''..··::····::··/|=|\··::····::··..'' | | ''-..····:··/|===|\··:····..-'' | | ╭─╮ ╭─╮ '''-..__/|===|\__..-''' ╭─╮ ╭─╮ | | │ │ │ │ /|===|\ │ │ │ │ | ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
use it
html
<script type="module" src="https://ascii.rest/ascii.js"></script> <ascii-art piece="sundial"></ascii-art>
No install and no build step. ascii.js defines the <ascii-art> tag, loads the piece from ascii.rest, plays it while it is on screen, and holds the first frame for anyone who prefers reduced motion.
react
// npm install github:bas3line/ascii
import { Ascii } from "ascii.rest/react";
import { sundial } from "ascii.rest/pieces";
<Ascii piece={sundial} /> A client component, so it works in the Next.js app router as it is. Pass the name instead, piece="sundial", to fetch the piece only when it mounts.
astro
--- // npm install github:bas3line/ascii import Ascii from "ascii.rest/astro"; --- <Ascii piece="sundial" />
Renders the first frame on the server, so the page is whole before any script runs, then plays the piece once it loads.
typescript
// npm install github:bas3line/ascii
import { mount } from "ascii.rest";
import { sundial } from "ascii.rest/pieces";
const el = document.querySelector<HTMLPreElement>("pre")!;
const stop = mount(el, sundial); mount plays a piece in a <pre>, or on a <canvas> for the coloured ones, and returns a function that stops it. Every piece, option and frame is typed.
/*
* sundial: the Samrat Yantra at Jantar Mantar, Jaipur, seen from the south.
* The gnomon's shadow lies on the quadrants where the sun at Jaipur would put
* it at this hour of the viewer's own clock (not Jaipur solar time).
*/
import type { Frame, Meta } from "../types.ts";
export const meta = {
name: "sundial",
category: "objects",
note: "jaipur's samrat yantra, its shadow set by your local time",
cols: 65,
rows: 23,
fps: 10,
clock: true,
} satisfies Meta;
const LAT = (26.92 * Math.PI) / 180; // Jaipur
const CX = 32.5, YC = 9.5; // the centre of the quadrants' arc, on the gnomon's edge
const RX = 26, RY = 11.6; // the arc's radius across and down, a cell being twice as tall as wide
const INNER = 0.77; // the inner edge of the quadrants' face, as a share of the outer
const GROUND = 22, APEX = 4, FOOT = 2.9; // the gnomon: ground row, top row, half width at the foot
function mulberry32(seed: number) {
let s = seed | 0;
return () => {
s = (s + 0x6d2b79f5) | 0;
let t = Math.imul(s ^ (s >>> 15), 1 | s);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
type FaceCell = [k: number, psi: number, lo: number, hi: number, hour: boolean];
type Tally = [n: number, fy: number, phi: number, west: boolean];
export default function sundial(): Frame {
const { cols, rows } = meta;
const grid: string[] = new Array(cols * rows);
const put = (c: number, r: number, g: string) => {
c = Math.floor(c), r = Math.floor(r);
if (c >= 0 && c < cols && r >= 0 && r < rows) grid[r * cols + c] = g;
};
const text = (c: number, r: number, s: string) => [...s].forEach((g, i) => g !== " " && put(c + i, r, g));
const angle = (c: number, r: number) => Math.atan2((c - CX) / RX, (r - YC) / RY); // 0 straight down, east positive
// The face of the quadrants, cell by cell: its angle round the arc, the
// span of angles its corners cover, and whether an hour line crosses it.
const face: FaceCell[] = [];
for (let r = Math.floor(YC); r < rows; r++) {
for (let c = 0; c < cols; c++) {
const e = Math.hypot((c + 0.5 - CX) / RX, (r + 0.5 - YC) / RY);
if (e >= 1 || e <= INNER) continue;
const corners = [[c, r], [c + 1, r], [c, r + 1], [c + 1, r + 1]].map(([x, y]) => angle(x, y));
const lo = Math.min(...corners), hi = Math.max(...corners);
const hour = Math.floor(hi / (Math.PI / 12)) !== Math.floor(lo / (Math.PI / 12));
face.push([r * cols + c, angle(c + 0.5, r + 0.5), lo, hi, hour]);
}
}
// The gnomon's half width on row r, and the least angle of the face that
// shows beside its foot.
const half = (r: number) => 0.6 + ((FOOT - 0.6) * (r - APEX)) / (GROUND - 1 - APEX);
let hidden = Math.PI / 2;
for (const [k, psi] of face) {
const r = Math.floor(k / cols), c = k % cols;
if (c < Math.floor(CX - half(r)) || c > Math.floor(CX + half(r))) hidden = Math.min(hidden, Math.abs(psi));
}
// The outer rim of the face, traced as a line: one cell a row where it
// stands upright, otherwise each cell takes the glyph for the slope and
// height of the arc inside it, and only one cell a row takes a slant.
const rim = new Map<number, string>();
const UP = 0.22; // how far down the rim stands upright, as a share of its depth
for (let r = Math.ceil(YC - 0.5); (r + 0.5 - YC) / RY < UP; r++) {
const w = RX * Math.sqrt(1 - ((r + 0.5 - YC) / RY) ** 2);
rim.set(r * cols + Math.floor(CX - w), "|"), rim.set(r * cols + Math.floor(CX + w), "|");
}
const acc = new Map<number, Tally>();
for (let i = 0; i <= 1200; i++) {
const p = ((i / 1200) * 2 - 1) * (Math.PI / 2);
const x = CX + RX * Math.sin(p), y = YC + RY * Math.cos(p);
if ((Math.floor(y) + 0.5 - YC) / RY < UP) continue;
const k = Math.floor(y) * cols + Math.floor(x);
const e = acc.get(k) || [0, 0, 0, p < 0];
(e[0] += 1), (e[1] += y - Math.floor(y)), (e[2] += Math.atan2(2 * RY * Math.abs(Math.sin(p)), RX * Math.cos(p)));
acc.set(k, e);
}
const most = new Map<number, number>(); // the fullest steep cell on each row and side
for (const [k, [n, , phi, west]] of acc) {
const id = Math.floor(k / cols) * 2 + (west ? 0 : 1);
if (phi / n > 1.08 && n > (acc.get(most.get(id) as number)?.[0] ?? 0)) most.set(id, k);
}
for (const [k, [n, fy, phi, west]] of acc) {
const s = most.get(Math.floor(k / cols) * 2 + (west ? 0 : 1));
if (s === k) rim.set(k, west ? "\\" : "/");
else if (s === undefined || n > acc.get(s)![0] * 0.7) rim.set(k, fy / n < 0.4 ? "'" : fy / n > 0.6 ? (phi / n > 0.2 ? "." : "_") : "-");
}
// Everything but the face's light and the sky, drawn once a minute.
const base: string[] = new Array(cols * rows);
const draw = (H: number | null) => {
grid.fill(" ");
// The face: lit marble with an hour line every fifteen degrees, and in
// daylight the gnomon's shadow from its foot out to the hour angle on
// the side away from the sun, its edge drawn hard. Near noon the edge
// would fall behind the gnomon, so it is drawn against its foot.
const edge = H === null ? null : Math.sign(H || -1) * Math.max(Math.abs(H), hidden);
for (const [k, psi, lo, hi, hour] of face) {
let g = hour ? ":" : "·";
if (H !== null) {
const d = (hi - lo) / 2; // a soft fringe either side of the edge
if (lo <= edge! && edge! <= hi) g = "█";
else if (lo - d <= edge! && edge! <= hi + d) g = "▓";
else if (psi * H >= 0 && Math.abs(psi) <= Math.abs(H)) g = "▒";
}
grid[k] = g;
}
for (const [k, g] of rim) grid[k] = g;
// Hour numbers just inside the face, morning on the west quadrant.
for (let h = -6; h <= 6; h++) {
if (!h) continue;
const p = (h * Math.PI) / 12, s = String(h < 0 ? 12 + h : h);
text(Math.round(CX + RX * (INNER - 0.09) * Math.sin(p) - s.length / 2), YC + RY * (INNER - 0.12) * Math.cos(p), s);
}
// The end walls the quadrants' tips stand on, and arches in the masonry.
for (const s of [-1, 1]) {
const wall = Math.floor(CX + s * RX);
for (let r = Math.floor(YC); r < GROUND; r++) put(wall, r, "|");
put(wall, Math.floor(YC) - 1, "_");
for (let c = 1; c < RX * (1 - INNER) - 0.5; c++) put(wall - s * c, Math.floor(YC) - 1, "_");
for (const dx of [16, 21]) text(Math.floor(CX + s * dx) - 1, GROUND - 2, "╭─╮"), text(Math.floor(CX + s * dx) - 1, GROUND - 1, "│ │");
}
for (let c = 0; c < cols; c++) put(c, GROUND, "▀");
// The gnomon in front, narrowing as it climbs away: its stairs up the
// middle between two rails, and the chhatri on top.
for (let r = APEX; r < GROUND; r++) {
const w = half(r);
for (let c = Math.ceil(CX - w); c < CX + w; c++) put(c, r, "=");
put(CX - w, r, "/"), put(CX + w, r, "\\");
if (w > 2) put(CX - w + 1, r, "|"), put(CX + w - 1, r, "|");
}
text(Math.floor(CX) - 1, APEX - 2, "▗█▖");
text(Math.floor(CX) - 1, APEX - 1, "│ │");
base.splice(0, base.length, ...grid);
};
const rand = mulberry32(5);
const stars = Array.from({ length: 22 }, (): [number, number, number] => [(rand() * cols) | 0, (rand() * 8) | 0, rand() * 6]);
let key = -1;
let H: number | null = null;
return (t) => {
const now = new Date();
const hours = now.getHours() + now.getMinutes() / 60;
const day = (now.getTime() - new Date(now.getFullYear(), 0, 1).getTime()) / 864e5;
const minute = Math.floor(hours * 60);
if (minute !== key) {
key = minute;
// The hour angle, 15 degrees an hour from noon, and whether the sun
// is up at Jaipur's latitude on this day of the year.
const h = ((hours - 12) * 15 * Math.PI) / 180;
const dec = (-23.44 * Math.PI * Math.cos((2 * Math.PI * (day + 10)) / 365)) / 180;
const up = Math.cos(dec) * Math.cos(h) * Math.cos(LAT) + Math.sin(dec) * Math.sin(LAT) > 0;
H = up ? Math.max(-Math.PI / 2, Math.min(Math.PI / 2, h)) : null;
draw(H);
}
const out = base.slice();
const sky = (c: number, r: number, g: string) => {
c = Math.floor(c), r = Math.floor(r);
if (c >= 0 && c < cols && r >= 0 && r < rows && out[r * cols + c] === " ") out[r * cols + c] = g;
};
if (H === null) {
for (const [c, r, ph] of stars) sky(c, r, Math.sin(t * 1.4 + ph) > 0.5 ? "+" : "·");
} else {
// The sun on its arc across the sky, east to the right, its rays
// flickering between upright, all round and slanted.
const x = CX - 24 * Math.sin(H), y = 1.5 + 4 * (1 - Math.cos(H));
sky(x, y, "O"), sky(x - 1, y, "("), sky(x + 1, y, ")");
const step = Math.floor(t / 0.45) % 4;
const rays: [number, number, string][][] = [[[0, -1, "|"], [0, 1, "|"], [-3, 0, "-"], [3, 0, "-"]], [[-2, -1, "\\"], [2, -1, "/"], [-2, 1, "/"], [2, 1, "\\"]]];
for (const [dx, dy, g] of step === 0 ? rays[0] : step === 2 ? rays[1] : [...rays[0], ...rays[1]]) sky(x + dx, y + dy, g);
}
const lines: string[] = [];
for (let r = 0; r < rows; r++) lines.push(out.slice(r * cols, (r + 1) * cols).join(""));
return lines.join("\n");
};
}