| 1 | /** @jsxImportSource @alchemy.run/sigil */ |
| 2 | /** |
| 3 | * The yantra logo, rasterized at runtime. Geometry mirrors |
| 4 | * website/src/brand/yantra.ts (the brand's source of truth): a downward |
| 5 | * equilateral triangle inscribed in a circle, bindu at the center. Rendered |
| 6 | * as braille dots (2x4 subpixels per cell) via signed distance functions, so |
| 7 | * it scales to any column width. |
| 8 | */ |
| 9 | import { Box, Text } from "../ui/index.ts"; |
| 10 | import { useMemo } from "@alchemy.run/sigil/react"; |
| 11 | import type { JSX } from "react"; |
| 12 | import { theme } from "../../CliKit/index.ts"; |
| 13 | |
| 14 | const CENTER = 12; |
| 15 | const CIRCLE_R = 9.5; |
| 16 | const BINDU_R = 1.1; |
| 17 | const DOT_THRESHOLD = 0.35; |
| 18 | // stroke thickness in braille subpixels, independent of logo size — keeps the |
| 19 | // outline delicate instead of thickening into solid slabs as the logo grows |
| 20 | const STROKE_SUBPIXELS = 2.5; |
| 21 | |
| 22 | const trianglePoints = ( |
| 23 | strokeW: number, |
| 24 | ): ReadonlyArray<readonly [number, number]> => { |
| 25 | const triR = CIRCLE_R - strokeW / 4; |
| 26 | const triDx = triR * Math.cos(Math.PI / 6); |
| 27 | const triTopY = CENTER - triR * Math.sin(Math.PI / 6); |
| 28 | const triApexY = CENTER + triR; |
| 29 | return [ |
| 30 | [CENTER, triApexY], |
| 31 | [CENTER - triDx, triTopY], |
| 32 | [CENTER + triDx, triTopY], |
| 33 | ]; |
| 34 | }; |
| 35 | |
| 36 | const sdCircleRing = (x: number, y: number, strokeW: number) => |
| 37 | Math.abs(Math.hypot(x - CENTER, y - CENTER) - CIRCLE_R) - strokeW / 2; |
| 38 | |
| 39 | const sdSegment = ( |
| 40 | px: number, |
| 41 | py: number, |
| 42 | ax: number, |
| 43 | ay: number, |
| 44 | bx: number, |
| 45 | by: number, |
| 46 | ) => { |
| 47 | const abx = bx - ax; |
| 48 | const aby = by - ay; |
| 49 | const apx = px - ax; |
| 50 | const apy = py - ay; |
| 51 | const t = Math.max( |
| 52 | 0, |
| 53 | Math.min(1, (apx * abx + apy * aby) / (abx * abx + aby * aby)), |
| 54 | ); |
| 55 | return Math.hypot(apx - t * abx, apy - t * aby); |
| 56 | }; |
| 57 | |
| 58 | const sdTriangle = ( |
| 59 | x: number, |
| 60 | y: number, |
| 61 | pts: ReadonlyArray<readonly [number, number]>, |
| 62 | strokeW: number, |
| 63 | ) => { |
| 64 | let d = Infinity; |
| 65 | for (let i = 0; i < 3; i++) { |
| 66 | const [ax, ay] = pts[i]; |
| 67 | const [bx, by] = pts[(i + 1) % 3]; |
| 68 | d = Math.min(d, sdSegment(x, y, ax, ay, bx, by)); |
| 69 | } |
| 70 | return d - strokeW / 2; |
| 71 | }; |
| 72 | |
| 73 | const sdBindu = (x: number, y: number) => |
| 74 | Math.hypot(x - CENTER, y - CENTER) - BINDU_R; |
| 75 | |
| 76 | // braille dot bit per (row 0-3, column 0-1) subpixel |
| 77 | const BRAILLE_BITS = [ |
| 78 | [0x01, 0x08], |
| 79 | [0x02, 0x10], |
| 80 | [0x04, 0x20], |
| 81 | [0x40, 0x80], |
| 82 | ] as const; |
| 83 | |
| 84 | interface LogoRun { |
| 85 | text: string; |
| 86 | color?: string; |
| 87 | } |
| 88 | |
| 89 | const rasterizeLogo = (cols: number): LogoRun[][] => { |
| 90 | const width = cols * 2; // subpixel grid, square (2 wide x 4 tall per cell) |
| 91 | const rows = Math.ceil(width / 4); |
| 92 | const scale = 24 / width; |
| 93 | const strokeW = STROKE_SUBPIXELS * scale; |
| 94 | const triPts = trianglePoints(strokeW); |
| 95 | const lines: LogoRun[][] = []; |
| 96 | for (let r = 0; r < rows; r++) { |
| 97 | const runs: LogoRun[] = []; |
| 98 | const push = (text: string, color?: string) => { |
| 99 | const last = runs[runs.length - 1]; |
| 100 | if (last && last.color === color) last.text += text; |
| 101 | else runs.push({ text, color }); |
| 102 | }; |
| 103 | for (let c = 0; c < cols; c++) { |
| 104 | let bits = 0; |
| 105 | let strokeSum = 0; |
| 106 | let binduSum = 0; |
| 107 | for (let sy = 0; sy < 4; sy++) { |
| 108 | for (let sx = 0; sx < 2; sx++) { |
| 109 | const x = (c * 2 + sx + 0.5) * scale; |
| 110 | const y = (r * 4 + sy + 0.5) * scale; |
| 111 | const dStroke = Math.min( |
| 112 | sdCircleRing(x, y, strokeW), |
| 113 | sdTriangle(x, y, triPts, strokeW), |
| 114 | ); |
| 115 | const strokeCov = Math.min(1, Math.max(0, 0.5 - dStroke / scale)); |
| 116 | const binduCov = Math.min( |
| 117 | 1, |
| 118 | Math.max(0, 0.5 - sdBindu(x, y) / scale), |
| 119 | ); |
| 120 | if (Math.max(strokeCov, binduCov) > DOT_THRESHOLD) { |
| 121 | bits |= BRAILLE_BITS[sy][sx]; |
| 122 | strokeSum += strokeCov; |
| 123 | binduSum += binduCov; |
| 124 | } |
| 125 | } |
| 126 | } |
| 127 | if (bits === 0) push(" "); |
| 128 | else { |
| 129 | push( |
| 130 | String.fromCharCode(0x2800 + bits), |
| 131 | binduSum > strokeSum ? theme.color.brand : theme.color.accent, |
| 132 | ); |
| 133 | } |
| 134 | } |
| 135 | // drop trailing whitespace runs |
| 136 | while (runs.length > 0) { |
| 137 | const last = runs[runs.length - 1]; |
| 138 | if (last.color !== undefined || last.text.trim() !== "") break; |
| 139 | runs.pop(); |
| 140 | } |
| 141 | lines.push(runs); |
| 142 | } |
| 143 | while (lines.length > 0 && lines[0].length === 0) lines.shift(); |
| 144 | while (lines.length > 0 && lines[lines.length - 1].length === 0) lines.pop(); |
| 145 | return lines; |
| 146 | }; |
| 147 | |
| 148 | // Braille rasterization is mojibake on non-Unicode terminals — the help |
| 149 | // formatter skips mounting the logo entirely when Unicode is unavailable. |
| 150 | type LogoProps = { cols: number }; |
| 151 | |
| 152 | export function Logo({ cols }: LogoProps): JSX.Element | null { |
| 153 | const lines = useMemo(() => rasterizeLogo(cols), [cols]); |
| 154 | return ( |
| 155 | <Box flexDirection="column" flexShrink={0}> |
| 156 | {lines.map((runs, i) => ( |
| 157 | <Text key={i}> |
| 158 | {runs.length === 0 |
| 159 | ? " " |
| 160 | : runs.map((run, j) => ( |
| 161 | <Text key={j} color={run.color}> |
| 162 | {run.text} |
| 163 | </Text> |
| 164 | ))} |
| 165 | </Text> |
| 166 | ))} |
| 167 | </Box> |
| 168 | ); |
| 169 | } |