diff --git a/src/components/simulators/HexSimulator.astro b/src/components/simulators/HexSimulator.astro new file mode 100644 index 0000000..4f212bb --- /dev/null +++ b/src/components/simulators/HexSimulator.astro @@ -0,0 +1,104 @@ +--- +import "./hex/hex-simulator.css"; +--- + + + + + DENARY + 0 + + HEXADECIMAL + 00 + + BINARY + 0000 0000 + + + + + + + + + + + + + + + + + + TOOLBOX + + + + + + + + + Custom + + + + + + + + Cancel + Apply + + + + + + diff --git a/src/components/simulators/hex/hex-simulator.css b/src/components/simulators/hex/hex-simulator.css new file mode 100644 index 0000000..8a15d61 --- /dev/null +++ b/src/components/simulators/hex/hex-simulator.css @@ -0,0 +1,346 @@ +/* ================= Fonts to match Binary ================= */ +/* Adjust paths to wherever you store fonts (commonly /public/fonts/...) */ +@font-face { + font-family: "DSEG7Classic"; + src: url("/fonts/DSEG7Classic-Regular.woff") format("woff"), + url("/fonts/DSEG7Classic-Regular.ttf") format("truetype"); + font-weight: 400; + font-style: normal; + font-display: swap; +} +@font-face { + font-family: "SevenSegment"; + src: url("/fonts/Seven-Segment.woff2") format("woff2"), + url("/fonts/Seven-Segment.woff") format("woff"); + font-weight: 400; + font-style: normal; + font-display: swap; +} + +.hex-sim { + min-height: 100vh; + background: #14151c; + color: #e7e8ee; + padding: 28px; +} + +.hex-font-number { font-family: "DSEG7Classic", ui-monospace, monospace; } +.hex-font-mono { font-family: "SevenSegment", ui-monospace, monospace; } + +.hex-main { max-width: 1200px; margin: 0 auto; width: 100%; padding-top: 40px; } + +.hex-readout { text-align: center; } +.hex-label { + font-family: "SevenSegment", ui-sans-serif, system-ui; + font-size: 12px; + letter-spacing: 2px; + opacity: 0.7; +} +.hex-mt { margin-top: 12px; } + +.hex-number { + font-family: "DSEG7Classic", ui-monospace, monospace; + font-size: 76px; + line-height: 1; + font-weight: 400; + color: #46ff8a; + text-shadow: 0 0 18px rgba(70,255,138,0.18); +} +.hex-number--small { font-size: 64px; } +.hex-number--tiny { font-size: 54px; letter-spacing: 6px; } + +.hex-divider { + margin: 26px auto 18px; + height: 1px; + width: min(760px, 90%); + background: rgba(255,255,255,0.10); +} + +/* ================= Main digit columns ================= */ +.hex-digits { + margin-top: 18px; + display: flex; + justify-content: center; + gap: 18px; + flex-wrap: wrap; +} + +.hex-digit-col { + width: 160px; + border-radius: 18px; + background: rgba(255,255,255,0.03); + border: 1px solid rgba(255,255,255,0.10); + padding: 12px; + display: grid; + gap: 10px; + justify-items: center; +} + +.hex-digit-controls { + width: 100%; + display: flex; + justify-content: center; + gap: 10px; +} + +.hex-digit-char { + font-size: 64px; + line-height: 1; + color: #46ff8a; + text-shadow: 0 0 18px rgba(70,255,138,0.18); +} + +.hex-digit-place { + font-family: "SevenSegment", ui-monospace, monospace; + opacity: 0.65; + font-size: 14px; + letter-spacing: 1px; +} + +/* ================= Bulbs (brightness changes) ================= */ +.hex-bulbs { + width: 100%; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + align-items: end; +} + +.hex-bulb { + display: grid; + justify-items: center; + gap: 6px; + opacity: 0.35; + filter: grayscale(30%); + transition: opacity 160ms ease, filter 160ms ease; +} + +.hex-bulb .hex-bulb-cap { + width: 18px; + height: 18px; + border-radius: 999px; + background: rgba(255,255,255,0.22); + border: 1px solid rgba(255,255,255,0.14); +} + +.hex-bulb .hex-bulb-glow { + width: 18px; + height: 10px; + border-radius: 999px; + background: rgba(70,255,138,0.0); + box-shadow: 0 0 0 rgba(70,255,138,0.0); + transition: background 160ms ease, box-shadow 160ms ease; +} + +.hex-bulb .hex-bulb-label { + font-family: "SevenSegment", ui-monospace, monospace; + font-size: 12px; + opacity: 0.8; +} + +.hex-bulb.is-on { + opacity: 1; + filter: none; +} +.hex-bulb.is-on .hex-bulb-cap { + background: rgba(255,255,255,0.35); +} +.hex-bulb.is-on .hex-bulb-glow { + background: rgba(70,255,138,0.25); + box-shadow: 0 0 18px rgba(70,255,138,0.35); +} + +/* ================= Buttons (toolbox style reused everywhere) ================= */ +.hex-btn { + padding: 10px 12px; + border-radius: 14px; + border: 1px solid rgba(255,255,255,0.14); + background: rgba(255,255,255,0.06); + color: #e7e8ee; + font-weight: 800; + cursor: pointer; + font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; +} +.hex-btn:hover { background: rgba(255,255,255,0.10); } + +.hex-btn--square { + width: 48px; + height: 48px; + padding: 0; + display: grid; + place-items: center; + font-size: 18px; +} + +.hex-btn--wide { width: 100%; } + +.hex-btn--green { + background: rgba(46, 200, 120, 0.18); + border-color: rgba(46,200,120,0.35); +} +.hex-btn--green:hover { background: rgba(46, 200, 120, 0.26); } + +.hex-btn--green2 { + background: rgba(46, 200, 120, 0.18); + border-color: rgba(46,200,120,0.35); +} + +.hex-btn--red { + background: rgba(220, 60, 70, 0.18); + border-color: rgba(220,60,70,0.35); +} + +/* Random = green pulse while running */ +.hex-btn--random.is-running { + border-color: rgba(80, 255, 160, 0.55); + background: rgba(46, 200, 120, 0.22); + box-shadow: 0 0 18px rgba(80, 255, 160, 0.35); + animation: hexPulseGreen 900ms ease-in-out infinite; +} +@keyframes hexPulseGreen { + 0%, 100% { box-shadow: 0 0 14px rgba(80, 255, 160, 0.25); } + 50% { box-shadow: 0 0 26px rgba(80, 255, 160, 0.45); } +} + +/* Reset = red background + pulse on hover */ +.hex-btn--reset:hover { + background: rgba(220, 60, 70, 0.28); + border-color: rgba(255, 80, 90, 0.55); + animation: hexPulseRed 900ms ease-in-out infinite; +} +@keyframes hexPulseRed { + 0%, 100% { box-shadow: 0 0 12px rgba(255, 80, 90, 0.20); } + 50% { box-shadow: 0 0 22px rgba(255, 80, 90, 0.38); } +} + +/* ================= Toolbox button + panel (slide) ================= */ +.hex-toolbox-btn { + position: fixed; + top: 88px; + right: 28px; + z-index: 30; + display: inline-flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-radius: 14px; + border: 1px solid rgba(255,255,255,0.14); + background: rgba(255,255,255,0.06); + color: #e7e8ee; + font-weight: 800; + letter-spacing: 1px; + cursor: pointer; +} + +.hex-toolbox-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + color: #ff4fa6; + filter: drop-shadow(0 0 10px rgba(255,79,166,0.35)); +} + +.hex-toolbox { + position: fixed; + top: 140px; + right: 28px; + width: 340px; + display: grid; + gap: 14px; + z-index: 25; + + transform: translateX(0); + opacity: 1; + transition: transform 220ms ease, opacity 220ms ease; +} +.hex-toolbox:not(.is-open) { + transform: translateX(380px); + opacity: 0; + pointer-events: none; +} + +.hex-panel { + border-radius: 16px; + background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.10); + padding: 14px; +} +.hex-panel-title { + font-family: "SevenSegment", ui-sans-serif, system-ui; + font-size: 12px; + letter-spacing: 2px; + opacity: 0.7; + margin-bottom: 10px; +} + +.hex-setting-title { font-weight: 900; opacity: 0.9; margin-bottom: 10px; } + +.hex-width { + display: grid; + grid-template-columns: 48px 1fr 48px; + gap: 10px; + align-items: center; +} +.hex-width-readout { + border-radius: 14px; + background: rgba(0,0,0,0.22); + border: 1px solid rgba(255,255,255,0.10); + padding: 10px 12px; + display: flex; + justify-content: space-between; + align-items: baseline; +} +.hex-width-label { + font-family: "SevenSegment", ui-sans-serif, system-ui; + opacity: 0.7; + font-weight: 800; + letter-spacing: 1px; + font-size: 12px; +} +.hex-width-number { font-size: 30px; font-weight: 900; color: #46ff8a; } + +.hex-hint { margin-top: 8px; opacity: 0.65; font-size: 12px; font-family: "SevenSegment", ui-sans-serif, system-ui; } + +.hex-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.hex-mt-sm { margin-top: 10px; } + +.hex-tools-top { display: flex; gap: 10px; justify-content: center; margin-bottom: 10px; } +.hex-tiny-note { margin-top: 8px; font-size: 11px; opacity: 0.6; letter-spacing: 1px; font-family: "SevenSegment", ui-sans-serif, system-ui; } + +/* ================= Dialog ================= */ +.hex-dialog { border: none; padding: 0; background: transparent; } +.hex-dialog::backdrop { background: rgba(0,0,0,0.55); } + +.hex-dialog-card { + width: min(560px, 92vw); + border-radius: 18px; + background: #1a1b24; + border: 1px solid rgba(255,255,255,0.12); + padding: 16px; + color: #e7e8ee; +} + +.hex-dialog-title { font-weight: 900; letter-spacing: 1px; margin-bottom: 10px; font-family: "SevenSegment", ui-sans-serif, system-ui; } + +.hex-dialog-input { + width: 100%; + padding: 12px 12px; + border-radius: 14px; + border: 1px solid rgba(255,255,255,0.14); + background: rgba(0,0,0,0.25); + color: #e7e8ee; + font-size: 18px; +} + +.hex-dialog-hint { margin-top: 10px; opacity: 0.7; font-size: 13px; font-family: "SevenSegment", ui-sans-serif, system-ui; } +.hex-dialog-error { margin-top: 8px; font-size: 13px; color: #ff6b6b; min-height: 18px; font-family: "SevenSegment", ui-sans-serif, system-ui; } +.hex-dialog-actions { margin-top: 14px; display: flex; gap: 10px; justify-content: flex-end; } + +@media (max-width: 900px) { + .hex-toolbox { width: min(360px, 92vw); right: 16px; } + .hex-toolbox-btn { right: 16px; } + .hex-number { font-size: 60px; } + .hex-number--tiny { font-size: 40px; letter-spacing: 4px; } +} diff --git a/src/components/simulators/hex/hex-simulator.ts b/src/components/simulators/hex/hex-simulator.ts new file mode 100644 index 0000000..08bc8aa --- /dev/null +++ b/src/components/simulators/hex/hex-simulator.ts @@ -0,0 +1,232 @@ +type DialogMode = "hex" | "den" | "bin"; + +const root = document.querySelector("[data-hex-sim]"); +if (!root) throw new Error("Hex simulator root not found"); + +const outDen = root.querySelector('[data-out="denary"]')!; +const outHex = root.querySelector('[data-out="hex"]')!; +const outBin = root.querySelector('[data-out="bin"]')!; +const outDigitsRow = root.querySelector('[data-out="digitsRow"]')!; + +const toolbox = root.querySelector('[data-out="toolbox"]')!; +const toolboxBtn = root.querySelector('[data-action="toggleToolbox"]')!; +const digitsCount = root.querySelector('[data-out="digitsCount"]')!; +const bitsHint = root.querySelector('[data-out="bitsHint"]')!; +const randomBtn = root.querySelector("[data-random]")!; + +const dialog = root.querySelector('[data-out="dialog"]')!; +const dialogTitle = root.querySelector('[data-out="dialogTitle"]')!; +const dialogInput = root.querySelector('[data-out="dialogInput"]')!; +const dialogHint = root.querySelector('[data-out="dialogHint"]')!; +const dialogError = root.querySelector('[data-out="dialogError"]')!; + +let digits = 2; // 1..8 +let value = 0; // unsigned denary +let randomTimer: number | null = null; +let dialogMode: DialogMode | null = null; + +const clamp = (n: number, min: number, max: number) => Math.min(max, Math.max(min, n)); +const maxForDigits = (d: number) => (16 ** d) - 1; + +const padHex = (n: number, d: number) => n.toString(16).toUpperCase().padStart(d, "0"); +const padBin = (n: number, b: number) => n.toString(2).padStart(b, "0"); +const groupBin = (b: string) => b.replace(/(.{4})/g, "$1 ").trim(); + +function stopRandom(): void { + if (randomTimer !== null) window.clearInterval(randomTimer); + randomTimer = null; + randomBtn.classList.remove("is-running"); +} + +function startRandom(): void { + stopRandom(); + const max = maxForDigits(digits); + const start = Date.now(); + + randomBtn.classList.add("is-running"); + + randomTimer = window.setInterval(() => { + value = Math.floor(Math.random() * (max + 1)); + render(); + if (Date.now() - start > 1600) stopRandom(); + }, 90); +} + +function render(): void { + const bits = digits * 4; + + digitsCount.textContent = String(digits); + bitsHint.textContent = `= ${bits} bits`; + + outDen.textContent = String(value); + outHex.textContent = padHex(value, digits); + outBin.textContent = groupBin(padBin(value, bits)); + + renderDigitsRow(); +} + +function renderDigitsRow(): void { + const hex = padHex(value, digits); + outDigitsRow.innerHTML = ""; + + for (let i = 0; i < digits; i++) { + const pow = digits - 1 - i; + const placeValue = 16 ** pow; + + const digitChar = hex[i]; + const digitVal = parseInt(digitChar, 16); + const nibbleBits = [(digitVal >> 3) & 1, (digitVal >> 2) & 1, (digitVal >> 1) & 1, digitVal & 1]; // 8 4 2 1 + + const col = document.createElement("div"); + col.className = "hex-digit-col"; + col.innerHTML = ` + + ▲ + ▼ + + + ${digitChar} + + + + ${[8,4,2,1].map((w, idx) => { + const on = nibbleBits[idx] === 1; + return ` + + + + ${w} + + `; + }).join("")} + + + ${placeValue} + `; + outDigitsRow.appendChild(col); + } +} + +function openDialog(mode: DialogMode): void { + stopRandom(); + dialogMode = mode; + + dialogError.textContent = ""; + dialogInput.value = ""; + + if (mode === "hex") { + dialogTitle.textContent = "Custom Hexadecimal"; + dialogHint.textContent = `Enter 1–${digits} hex digit(s) (0–9, A–F).`; + dialogInput.placeholder = "A1"; + dialogInput.inputMode = "text"; + } else if (mode === "den") { + dialogTitle.textContent = "Custom Denary"; + dialogHint.textContent = `Enter a whole number from 0 to ${maxForDigits(digits)}.`; + dialogInput.placeholder = "42"; + dialogInput.inputMode = "numeric"; + } else { + dialogTitle.textContent = "Custom Binary"; + dialogHint.textContent = `Enter up to ${digits * 4} bit(s) using 0 and 1.`; + dialogInput.placeholder = "00101010"; + dialogInput.inputMode = "text"; + } + + dialog.showModal(); + window.setTimeout(() => dialogInput.focus(), 0); +} + +function closeDialog(): void { + dialogMode = null; + dialogError.textContent = ""; + if (dialog.open) dialog.close(); +} + +function applyDialog(): void { + const raw = (dialogInput.value || "").trim(); + if (!dialogMode) return closeDialog(); + if (raw.length === 0) return closeDialog(); + + const max = maxForDigits(digits); + const bits = digits * 4; + + if (dialogMode === "hex") { + const v = raw.toUpperCase(); + if (!/^[0-9A-F]+$/.test(v)) { dialogError.textContent = "Hex must use 0–9 and A–F only."; return; } + if (v.length > digits) { dialogError.textContent = `Max length is ${digits} hex digit(s).`; return; } + value = clamp(parseInt(v, 16), 0, max); + render(); + return closeDialog(); + } + + if (dialogMode === "den") { + if (!/^\d+$/.test(raw)) { dialogError.textContent = "Denary must be whole numbers only."; return; } + const n = Number(raw); + if (!Number.isFinite(n)) { dialogError.textContent = "Invalid number."; return; } + value = clamp(n, 0, max); + render(); + return closeDialog(); + } + + // bin + if (!/^[01]+$/.test(raw)) { dialogError.textContent = "Binary must use 0 and 1 only."; return; } + if (raw.length > bits) { dialogError.textContent = `Max length is ${bits} bit(s).`; return; } + value = clamp(parseInt(raw, 2), 0, max); + render(); + return closeDialog(); +} + +function applyDigitDelta(i: number, delta: number): void { + stopRandom(); + const hexArr = padHex(value, digits).split(""); + let v = parseInt(hexArr[i], 16); + v = (v + delta) % 16; + if (v < 0) v += 16; + hexArr[i] = v.toString(16).toUpperCase(); + value = clamp(parseInt(hexArr.join(""), 16), 0, maxForDigits(digits)); + render(); +} + +// dialog cancel / backdrop +dialog.addEventListener("cancel", (e) => { e.preventDefault(); closeDialog(); }); +dialog.addEventListener("click", (e) => { + const card = dialog.querySelector(".hex-dialog-card"); + if (card && !card.contains(e.target as Node)) closeDialog(); +}); +dialogInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") applyDialog(); + if (e.key === "Escape") closeDialog(); +}); + +// main click handler +root.addEventListener("click", (e) => { + const btn = (e.target as HTMLElement).closest("[data-action]"); + if (!btn) return; + const action = btn.getAttribute("data-action")!; + + if (action === "toggleToolbox") { + toolbox.classList.toggle("is-open"); + toolboxBtn.setAttribute("aria-expanded", toolbox.classList.contains("is-open") ? "true" : "false"); + return; + } + + if (action === "digitsMinus") { digits = clamp(digits - 1, 1, 8); value = clamp(value, 0, maxForDigits(digits)); return render(); } + if (action === "digitsPlus") { digits = clamp(digits + 1, 1, 8); value = clamp(value, 0, maxForDigits(digits)); return render(); } + + if (action === "increment") { stopRandom(); value = clamp(value + 1, 0, maxForDigits(digits)); return render(); } + if (action === "decrement") { stopRandom(); value = clamp(value - 1, 0, maxForDigits(digits)); return render(); } + + if (action === "reset") { stopRandom(); value = 0; return render(); } + if (action === "random") { return startRandom(); } + + if (action === "customHex") return openDialog("hex"); + if (action === "customDenary") return openDialog("den"); + if (action === "customBinary") return openDialog("bin"); + + if (action === "dialogCancel") return closeDialog(); + if (action === "dialogApply") return applyDialog(); + + if (action === "digitUp") return applyDigitDelta(Number(btn.getAttribute("data-i")), +1); + if (action === "digitDown") return applyDigitDelta(Number(btn.getAttribute("data-i")), -1); +}); + +render(); diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 95ef8ae..022fd45 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -1,7 +1,5 @@ --- -const { - title = "Computing:Box", -} = Astro.props; +const { title = "Computing:Box" } = Astro.props; --- @@ -10,41 +8,10 @@ const { {title} - - - - - - - - - - Computing:Box - - - - About - Binary - Hexadecimal - Hex Colours - Logic Gates - - - - - + + + + + + + + COMPUTING:BOX + + + + ABOUT + BINARY + HEXADECIMAL + HEX COLOURS + LOGIC GATES + + + + + + +