From 3053a6d7131ca2ed2db9d3a58340eb033989ce02 Mon Sep 17 00:00:00 2001 From: dev Date: Mon, 1 Jun 2026 16:52:25 -0500 Subject: [PATCH] feat(ui): Dialog with render, drag, mode toggle, milestone dropdown --- src/ui.js | 238 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 src/ui.js diff --git a/src/ui.js b/src/ui.js new file mode 100644 index 0000000..183eeaf --- /dev/null +++ b/src/ui.js @@ -0,0 +1,238 @@ +import { computeEstimate } from './pure.js'; + +const STYLE = ` +.tat-root { + position: fixed; z-index: 99999; min-width: 320px; max-width: 420px; + background: #2b2b2b; color: #ddd; border: 1px solid #444; border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + font: 13px/1.4 Tahoma, Verdana, sans-serif; + padding: 12px 14px; +} +.tat-header { + display: flex; justify-content: space-between; align-items: center; + padding-bottom: 8px; margin-bottom: 10px; + border-bottom: 1px solid #444; + cursor: move; user-select: none; +} +.tat-header strong { color: #fff; } +.tat-close { cursor: pointer; opacity: 0.6; padding: 0 4px; } +.tat-close:hover { opacity: 1; } +.tat-row { display: flex; justify-content: space-between; padding: 2px 0; } +.tat-row.tat-target input, .tat-row.tat-target select { + background: #1a1a1a; color: #ddd; border: 1px solid #555; padding: 2px 4px; + font: inherit; font-size: 12px; +} +.tat-hr { border: none; border-top: 1px solid #444; margin: 8px 0; } +.tat-modes { display: flex; gap: 6px; margin-top: 12px; } +.tat-modes button { + flex: 1; padding: 4px; background: #2b2b2b; color: #ddd; + border: 1px solid #555; font: inherit; font-size: 11px; cursor: pointer; +} +.tat-modes button.active { background: #444; border-color: #888; } +.tat-warn { color: #c90; margin-top: 6px; font-size: 11px; } +.tat-error { padding: 8px 0; color: #f88; } +.tat-error button { margin-left: 8px; } +`; + +const MILESTONES = [ + { label: 'Custom', value: null }, + { label: '1M', value: 1_000_000 }, + { label: '5M', value: 5_000_000 }, + { label: '10M', value: 10_000_000 }, + { label: '25M', value: 25_000_000 }, + { label: '50M', value: 50_000_000 }, + { label: '100M', value: 100_000_000 }, + { label: '250M', value: 250_000_000 }, + { label: '500M', value: 500_000_000 }, + { label: '1B', value: 1_000_000_000 }, +]; + +function fmt(n) { + if (n == null) return '—'; + if (n >= 1e9) return (n / 1e9).toFixed(2).replace(/\.?0+$/, '') + 'B'; + if (n >= 1e6) return (n / 1e6).toFixed(2).replace(/\.?0+$/, '') + 'M'; + if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.?0+$/, '') + 'K'; + return String(n); +} + +function fmtFull(n) { + if (n == null) return '—'; + return Math.round(n).toLocaleString('en-US'); +} + +function fmtDate(d) { + if (!d) return '—'; + return d.toLocaleDateString('en-US', { weekday: 'short', day: '2-digit', month: 'short', year: 'numeric' }); +} + +export class Dialog { + constructor({ onTargetChange, onModeChange, onPosChange, onClose } = {}) { + this.onTargetChange = onTargetChange; + this.onModeChange = onModeChange; + this.onPosChange = onPosChange; + this.onClose = onClose; + this.root = null; + this.dragState = null; + this.mode = 'free'; + this.warn = null; + } + + mount({ initialMode = 'free', initialPos = { x: 0, y: 0 } } = {}) { + if (this.root) return; + injectStyles(); + + const root = document.createElement('div'); + root.className = 'tat-root'; + root.dataset.tat = '1'; + document.body.appendChild(root); + this.root = root; + this.mode = initialMode; + + if (initialMode === 'free') { + root.style.bottom = '20px'; + root.style.right = '20px'; + if (initialPos.x || initialPos.y) { + root.style.transform = `translate(${initialPos.x}px, ${initialPos.y}px)`; + } + } + + this._wireHeaderDrag(); + } + + destroy() { + if (this.root && this.root.parentNode) this.root.parentNode.removeChild(this.root); + this.root = null; + } + + setMode(mode, anchorInfo) { + this.mode = mode; + if (!this.root) return; + this.root.style.transform = ''; + this.root.style.top = ''; + this.root.style.bottom = ''; + this.root.style.left = ''; + this.root.style.right = ''; + if (mode === 'free') { + this.root.style.bottom = '20px'; + this.root.style.right = '20px'; + } else if (anchorInfo && anchorInfo.canAnchor) { + this._positionAnchored(anchorInfo.rect); + } else { + this.root.style.top = '20px'; + this.root.style.left = '50%'; + this.root.style.transform = 'translateX(-50%)'; + } + } + + _positionAnchored(rect) { + const dialogRect = this.root.getBoundingClientRect(); + let top = rect.top - dialogRect.height - 8; + if (top < 8) top = 20; + let left = rect.left + (rect.width - dialogRect.width) / 2; + if (left < 8) left = 8; + if (left + dialogRect.width > window.innerWidth - 8) left = window.innerWidth - dialogRect.width - 8; + this.root.style.top = `${top}px`; + this.root.style.left = `${left}px`; + } + + _wireHeaderDrag() { + const onDown = (e) => { + if (this.mode !== 'free') return; + if (e.target.classList.contains('tat-close')) return; + const rect = this.root.getBoundingClientRect(); + this.dragState = { dx: e.clientX - rect.left, dy: e.clientY - rect.top }; + e.preventDefault(); + }; + const onMove = (e) => { + if (!this.dragState) return; + const x = e.clientX - this.dragState.dx; + const y = e.clientY - this.dragState.dy; + this.root.style.left = `${x}px`; + this.root.style.top = `${y}px`; + this.root.style.right = 'auto'; + this.root.style.bottom = 'auto'; + }; + const onUp = () => { + if (!this.dragState) return; + const rect = this.root.getBoundingClientRect(); + this.dragState = null; + this.onPosChange && this.onPosChange({ x: rect.left, y: rect.top }); + }; + this.root.addEventListener('mousedown', onDown); + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + } + + render(state) { + if (!this.root) return; + const { attr, gym, current, target, perTrain, summary, error, warn } = state; + if (error) { + this.root.innerHTML = ` +
⚙ Attribute Tracker
+
${error}
+ `; + this.root.querySelector('[data-action="reload"]').onclick = () => location.reload(); + this.root.querySelector('.tat-close').onclick = () => this.onClose && this.onClose(); + return; + } + + const est = computeEstimate(current, target || 0, perTrain || 0, summary?.perDay || 0); + + const modes = ['free', 'anchored'].map((m) => + `` + ).join(''); + + const milestoneOptions = MILESTONES.map((m) => { + const sel = m.value === target ? 'selected' : ''; + return ``; + }).join(''); + + this.root.innerHTML = ` +
+ ⚙ Attribute Tracker + +
+
Attribute${attr || '—'} · ${gym || '—'}
+
Current${fmtFull(current)}
+
+ Target + + + + +
+
+
Per train${perTrain ? '+ ' + fmtFull(perTrain) : '—'}
+
Trains today${fmtFull(summary?.trainsToday ?? 0)}
+
7-day avg${summary ? summary.sevenDayAvgPerDay.toFixed(1) : '0.0'} / day
+
Per day${summary && summary.perDay > 0 ? '+ ' + fmtFull(summary.perDay) : '—'}
+
+
Remaining${fmtFull(est.remaining)}
+
Trains to go≈ ${fmtFull(est.trainsToGo)}
+
ETA${est.days > 0 ? `~ ${fmtFull(est.days)} days (${fmtDate(est.eta)})` : '—'}
+
${modes}
+ ${warn ? `
⚠ ${warn}
` : ''} + `; + + this.root.querySelector('.tat-close').onclick = () => this.onClose && this.onClose(); + this.root.querySelector('[data-role="target"]').onchange = (e) => { + this.onTargetChange && this.onTargetChange(e.target.value); + }; + this.root.querySelector('[data-role="milestone"]').onchange = (e) => { + const v = e.target.value; + if (v !== '') this.onTargetChange && this.onTargetChange(Number(v)); + }; + this.root.querySelectorAll('[data-mode]').forEach((btn) => { + btn.onclick = () => this.onModeChange && this.onModeChange(btn.dataset.mode); + }); + } +} + +let styleInjected = false; +function injectStyles() { + if (styleInjected) return; + const s = document.createElement('style'); + s.textContent = STYLE; + document.head.appendChild(s); + styleInjected = true; +}