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 = ` +