diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/template.html | 269 |
1 files changed, 269 insertions, 0 deletions
diff --git a/web/template.html b/web/template.html new file mode 100644 index 0000000..5349e38 --- /dev/null +++ b/web/template.html @@ -0,0 +1,269 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8"> +<meta name="viewport" content="width=device-width, initial-scale=1"> +<title>Stock RSI / KD</title> +<style> + :root { + --bg: #ffffff; --fg: #1d2330; --muted: #7a8294; --grid: #e6e9ef; --panel: #f6f7fa; + --up: #d6323c; --down: #1f9d55; --line1: #2563eb; --line2: #e08a00; --accent: #6d4aff; + } + @media (prefers-color-scheme: dark) { + :root { --bg: #14171f; --fg: #e4e7ee; --muted: #8a92a6; --grid: #262b38; --panel: #1b1f2a; + --up: #ff5d66; --down: #34c77b; --line1: #5b8cff; --line2: #ffb038; --accent: #9a82ff; } + } + html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); + font: 13px/1.4 system-ui, sans-serif; } + body { display: flex; flex-direction: column; } + header { display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: center; padding: 8px 16px; + border-bottom: 1px solid var(--grid); } + header h1 { font-size: 15px; margin: 0; } + #info { flex: 1 1 100%; font-variant-numeric: tabular-nums; min-height: 1.4em; } + #info b { font-weight: 600; } + label, button { color: var(--muted); } + button { background: var(--panel); border: 1px solid var(--grid); border-radius: 4px; padding: 2px 8px; cursor: pointer; } + button:hover { color: var(--fg); } + #wrap { flex: 1; min-height: 0; position: relative; } + svg { width: 100%; height: 100%; display: block; touch-action: none; user-select: none; cursor: crosshair; } + svg text { fill: var(--muted); font-size: 11px; } + svg text.title { fill: var(--fg); font-weight: 600; } + .grid { stroke: var(--grid); stroke-width: 1; } + .guide { stroke: var(--muted); stroke-width: 1; stroke-dasharray: 4 3; } +</style> +</head> +<body> +<header> + <h1 id="title"></h1> + <label>Colours <select id="scheme"><option value="tw">Red up / green down (TW)</option> + <option value="us">Green up / red down (US)</option></select></label> + <button id="reset">Reset zoom</button> + <button id="clear">Clear pins</button> + <span style="color:var(--muted)">wheel: zoom · drag: pan · click: pin a day (shows change vs pin) · triangles: K/D crosses · dashed lines: regular divergence (reversal) · dotted lines: hidden divergence (continuation)</span> + <div id="info"></div> +</header> +<div id="wrap"><svg id="svg"></svg></div> +<script> +const D = /*__DATA__*/null; +const N = D.candles.length; +const $ = id => document.getElementById(id); +const svg = $('svg'); +const M = { l: 12, r: 52, t: 6, b: 20 }; +const MIN_SPAN = Math.min(10, N); + +let start = Math.max(0, N - 120), end = N; // visible window, in bar indices (float) +let hover = null, pins = []; +let scheme = 'tw'; +try { scheme = localStorage.getItem('scheme') || 'tw'; } catch (e) {} +$('scheme').value = scheme; +$('title').textContent = `${D.name} · RSI(${D.rsiPeriod}) KD(${D.kdPeriod}) · ${D.candles[0].date} – ${D.candles[N-1].date}`; + +const css = n => getComputedStyle(document.documentElement).getPropertyValue(n).trim(); +const fmt = (v, d = 2) => v == null ? '–' : v.toFixed(d); +const fmtVol = v => v >= 1e8 ? (v/1e8).toFixed(2)+'億' : v >= 1e4 ? (v/1e4).toFixed(1)+'萬' : String(v); +function colors() { + const up = css('--up'), down = css('--down'); + return scheme === 'tw' ? { up, down } : { up: down, down: up }; +} + +let W = 0, H = 0, plotW = 0, panels = []; +function layout() { + W = svg.clientWidth; H = svg.clientHeight; + plotW = W - M.l - M.r; + const h = H - M.t - M.b, gap = 14; + const hp = Math.round((h - 2 * gap) * 0.5), hs = Math.round((h - 2 * gap) * 0.25); + panels = [ + { id: 'price', y: M.t, h: hp }, + { id: 'rsi', y: M.t + hp + gap, h: hs }, + { id: 'kd', y: M.t + hp + hs + 2 * gap, h: h - hp - hs - 2 * gap }, + ]; +} +const xOf = i => M.l + (i - start + 0.5) * plotW / (end - start); +const idxAt = px => Math.floor(start + (px - M.l) / plotW * (end - start)); +const clampWin = () => { + let span = Math.min(Math.max(end - start, MIN_SPAN), N); + start = Math.min(Math.max(start, 0), N - span); end = start + span; +}; + +function niceTicks(lo, hi, n) { + const step0 = (hi - lo) / n, p = Math.pow(10, Math.floor(Math.log10(step0))); + const step = [1, 2, 5, 10].map(m => m * p).find(s => s >= step0); + const out = []; + for (let v = Math.ceil(lo / step) * step; v <= hi; v += step) out.push(v); + return out; +} + +function render() { + layout(); + if (W <= 0) return; + const lo = Math.max(0, Math.floor(start)), hi = Math.min(N - 1, Math.ceil(end)); + const bw = plotW / (end - start); + const col = colors(); + let s = ''; + const [P, R, K] = panels; + + // ---- price panel scale + let pmin = Infinity, pmax = -Infinity, vmax = 0; + for (let i = lo; i <= hi; i++) { + const c = D.candles[i]; + pmin = Math.min(pmin, c.Low); pmax = Math.max(pmax, c.High); vmax = Math.max(vmax, c.Volume); + } + const pad = (pmax - pmin || 1) * 0.06; pmin -= pad; pmax += pad; + const volH = P.h * 0.16; + const py = v => P.y + (pmax - v) / (pmax - pmin) * (P.h - volH - 4); + const ind = (p, v) => p.y + (100 - v) / 100 * p.h; + + // ---- grids / axes + for (const t of niceTicks(pmin, pmax, 5)) { + s += `<line class="grid" x1="${M.l}" x2="${W-M.r}" y1="${py(t)}" y2="${py(t)}"/>` + + `<text x="${W-M.r+4}" y="${py(t)+4}">${+t.toFixed(2)}</text>`; + } + for (const [p, name, lines] of [[R, `RSI(${D.rsiPeriod})`, [30, 70]], [K, `KD(${D.kdPeriod})`, [20, 80]]]) { + s += `<rect x="${M.l}" y="${p.y}" width="${plotW}" height="${p.h}" fill="${css('--panel')}" opacity=".5"/>`; + for (const t of [0, 50, 100]) s += `<line class="grid" x1="${M.l}" x2="${W-M.r}" y1="${ind(p,t)}" y2="${ind(p,t)}"/>`; + for (const t of lines) s += `<line x1="${M.l}" x2="${W-M.r}" y1="${ind(p,t)}" y2="${ind(p,t)}" stroke="${css('--muted')}" stroke-dasharray="3 3"/>` + + `<text x="${W-M.r+4}" y="${ind(p,t)+4}">${t}</text>`; + s += `<text class="title" x="${M.l+4}" y="${p.y+12}">${name}</text>`; + } + // x ticks + const target = Math.max(1, Math.floor(plotW / 90)), every = Math.max(1, Math.round((end - start) / target)); + for (let i = lo; i <= hi; i++) { + if (i % every) continue; + const x = xOf(i); + s += `<line class="grid" x1="${x}" x2="${x}" y1="${P.y}" y2="${K.y+K.h}"/>` + + `<text x="${x}" y="${K.y+K.h+14}" text-anchor="middle">${D.candles[i].date.slice(every > 30 ? 0 : 2)}</text>`; + } + + // ---- candles + volume + const bodyW = Math.max(1, bw * 0.7); + for (let i = lo; i <= hi; i++) { + const c = D.candles[i], x = xOf(i); + const prev = i > 0 ? D.candles[i-1].Close : c.Open; + const colr = c.Close > c.Open || (c.Close === c.Open && c.Close >= prev) ? col.up : col.down; + if (bw >= 3) { + const top = py(Math.max(c.Open, c.Close)), bot = py(Math.min(c.Open, c.Close)); + s += `<line x1="${x}" x2="${x}" y1="${py(c.High)}" y2="${py(c.Low)}" stroke="${colr}"/>` + + `<rect x="${x-bodyW/2}" y="${top}" width="${bodyW}" height="${Math.max(1, bot-top)}" fill="${colr}"/>`; + } else { + s += `<line x1="${x}" x2="${x}" y1="${py(c.High)}" y2="${py(c.Low)}" stroke="${colr}"/>`; + } + const vh = vmax ? c.Volume / vmax * volH : 0; + s += `<rect x="${x-bodyW/2}" y="${P.y+P.h-vh}" width="${bodyW}" height="${vh}" fill="${colr}" opacity=".35"/>`; + } + + // ---- indicator lines (break on nulls) + const poly = (arr, p, color) => { + let d = '', pen = false; + for (let i = lo; i <= hi; i++) { + const v = arr[i]; + if (v == null) { pen = false; continue; } + d += `${pen ? 'L' : 'M'}${xOf(i).toFixed(1)},${ind(p, v).toFixed(1)}`; pen = true; + } + return `<path d="${d}" fill="none" stroke="${color}" stroke-width="1.5"/>`; + }; + s += poly(D.rsi, R, css('--accent')) + poly(D.k, K, css('--line1')) + poly(D.d, K, css('--line2')); + s += `<text x="${M.l+70}" y="${K.y+12}" style="fill:${css('--line1')}">K</text>` + + `<text x="${M.l+84}" y="${K.y+12}" style="fill:${css('--line2')}">D</text>`; + + // ---- signals + for (const c of D.crosses) { + if (c.index < lo || c.index > hi) continue; + const x = xOf(c.index), y = ind(K, D.k[c.index]), g = c.kind === 'golden'; + const sz = c.strong ? 6 : 4; + s += `<path d="M${x},${y + (g ? -2 : 2)} l${-sz},${g ? -sz*1.6 : sz*1.6} h${2*sz}z" fill="${g ? css('--line1') : css('--line2')}" opacity="${c.strong ? 1 : .55}"/>`; + } + for (const dv of D.divergences) { + if (dv.i2 < lo || dv.i1 > hi) continue; + const bull = dv.kind === 'bullish', color = bull ? css('--line1') : css('--line2'); + const arr = dv.indicator === 'RSI' ? D.rsi : D.k, p = dv.indicator === 'RSI' ? R : K; + // regular (reversal) = dashed, hidden (continuation) = dotted + const dash = dv.class === 'hidden' + ? `stroke="${color}" stroke-width="2" stroke-dasharray="1 4" stroke-linecap="round"` + : `stroke="${color}" stroke-width="1.5" stroke-dasharray="5 3"`; + s += `<line x1="${xOf(dv.i1)}" y1="${py(dv.p1)}" x2="${xOf(dv.i2)}" y2="${py(dv.p2)}" ${dash} opacity=".7"/>` + + `<line x1="${xOf(dv.j1)}" y1="${ind(p, arr[dv.j1])}" x2="${xOf(dv.j2)}" y2="${ind(p, arr[dv.j2])}" ${dash}/>`; + } + + // ---- pins and crosshair + const guide = (i, cls, extra = '') => { + const x = xOf(i); + return `<line class="${cls}" x1="${x}" x2="${x}" y1="${P.y}" y2="${K.y+K.h}" ${extra}/>`; + }; + for (const i of pins) if (i >= lo && i <= hi) s += guide(i, 'guide', `stroke="${css('--accent')}" stroke-dasharray="none"`); + if (hover != null) { + s += guide(hover, 'guide'); + const c = D.candles[hover]; + s += `<line class="guide" x1="${M.l}" x2="${W-M.r}" y1="${py(c.Close)}" y2="${py(c.Close)}"/>` + + `<text x="${W-M.r+4}" y="${py(c.Close)+4}" style="fill:${css('--fg')};font-weight:600">${fmt(c.Close)}</text>`; + } + svg.innerHTML = s; + updateInfo(); +} + +function updateInfo() { + const i = hover != null ? hover : N - 1, c = D.candles[i], col = colors(); + const prev = i > 0 ? D.candles[i-1].Close : null; + const chg = prev ? (c.Close - prev) / prev * 100 : null; + const cc = chg == null ? '' : `style="color:${chg >= 0 ? col.up : col.down}"`; + let t = `<b>${c.date}</b> O ${fmt(c.Open)} H ${fmt(c.High)} L ${fmt(c.Low)} ` + + `C <b ${cc}>${fmt(c.Close)}</b> <span ${cc}>(${chg == null ? '–' : (chg >= 0 ? '+' : '') + chg.toFixed(2) + '%'})</span> ` + + `Vol ${fmtVol(c.Volume)} <span style="color:${css('--accent')}">RSI ${fmt(D.rsi[i], 1)}</span> ` + + `<span style="color:${css('--line1')}">K ${fmt(D.k[i], 1)}</span> <span style="color:${css('--line2')}">D ${fmt(D.d[i], 1)}</span>`; + if (pins.length) { + const p = pins[pins.length - 1], pc = D.candles[p]; + const diff = c.Close - pc.Close, pct = diff / pc.Close * 100; + const days = Math.abs(i - p); + t += ` | vs pin ${pc.date}: <b style="color:${diff >= 0 ? col.up : col.down}">${diff >= 0 ? '+' : ''}${diff.toFixed(2)} (${diff >= 0 ? '+' : ''}${pct.toFixed(2)}%)</b>, ${days} bars`; + } + $('info').innerHTML = t; +} + +// ---- interaction +function svgX(e) { return e.clientX - svg.getBoundingClientRect().left; } +svg.addEventListener('wheel', e => { + e.preventDefault(); + const span = end - start; + if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) { // horizontal scroll pans + const shift = e.deltaX / plotW * span; start += shift; end += shift; + } else { // wheel / pinch zooms around cursor + const frac = Math.min(1, Math.max(0, (svgX(e) - M.l) / plotW)); + const anchor = start + frac * span; + const ns = Math.min(Math.max(span * Math.exp(e.deltaY * (e.ctrlKey ? 0.01 : 0.002)), MIN_SPAN), N); + start = anchor - frac * ns; end = start + ns; + } + clampWin(); render(); +}, { passive: false }); + +let drag = null; +svg.addEventListener('pointerdown', e => { drag = { x: svgX(e), start, moved: false }; svg.setPointerCapture(e.pointerId); }); +svg.addEventListener('pointermove', e => { + const x = svgX(e); + if (drag) { + const dx = x - drag.x; + if (Math.abs(dx) > 3) drag.moved = true; + if (drag.moved) { + const span = end - start; start = drag.start - dx / plotW * span; end = start + span; clampWin(); + } + } + const i = idxAt(x); + hover = i >= 0 && i < N ? i : null; + render(); +}); +svg.addEventListener('pointerup', e => { + if (drag && !drag.moved && hover != null) { + const k = pins.indexOf(hover); + if (k >= 0) pins.splice(k, 1); else pins.push(hover); + render(); + } + drag = null; +}); +svg.addEventListener('pointerleave', () => { if (!drag) { hover = null; render(); } }); +$('reset').onclick = () => { start = 0; end = N; render(); }; +$('clear').onclick = () => { pins = []; render(); }; +$('scheme').onchange = e => { scheme = e.target.value; try { localStorage.setItem('scheme', scheme); } catch (_) {} render(); }; +new ResizeObserver(render).observe($('wrap')); +matchMedia('(prefers-color-scheme: dark)').addEventListener('change', render); +clampWin(); render(); +</script> +</body> +</html> |
