diff --git a/.env.example b/.env.example index 1ede2e8..0250fd8 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,8 @@ SCHWAB_SYMBOL=/ES TIMEFRAMES=1m,5m,15m,30m,1h,1d BASE_TIMEFRAMES=1m,30m,1d MAX_BARS_PER_TF=5000 +# Emergency rollback: false restores the previous displayed/1m trendline geometry. +TRENDLINE_SOURCE_GEOMETRY=true MA_SETS__1D=sma10,sma20,sma50,sma100,sma200 DAILY_ANCHOR_ET=18:00 MANUAL_LINES_PATH=./data/manual_lines.json diff --git a/README.md b/README.md index bd2161e..a9c94b2 100644 --- a/README.md +++ b/README.md @@ -381,3 +381,9 @@ Manual trendlines are written to `MANUAL_LINES_PATH` (`./data/manual_lines.json` In production `/app/data` is a **Coolify persistent volume** — without it the container filesystem is ephemeral and every deploy would silently wipe every line you've drawn. + +Sloped lines are priced in the bar space of the timeframe on which they were +drawn, then sampled onto the displayed candles. This keeps a 30m line in the +same place on 30m and 1m and makes the chart agree with alert pricing. Set +`TRENDLINE_SOURCE_GEOMETRY=false` and restart for an immediate rollback to the +previous displayed/1m-grid behavior; drawing data is unchanged either way. diff --git a/app/analysis/bar_space.py b/app/analysis/bar_space.py index 24054e3..4093c43 100644 --- a/app/analysis/bar_space.py +++ b/app/analysis/bar_space.py @@ -9,6 +9,9 @@ disagree — measured at 147 points across a weekend on a real /ES chart. """ from bisect import bisect_right +from app.bars.models import Timeframe +from app.bars.session import bucket_duration + def index_at(times: list[int], t: int) -> float: """Fractional index of a timestamp within an ascending bar-time series.""" @@ -39,3 +42,49 @@ def price_in_bar_space(level, times: list[int], t: int) -> float: end_price = level.anchor_p + level.slope * (level.last_t - level.anchor_t) ratio = (index_at(times, t) - start_index) / (end_index - start_index) return level.anchor_p + (end_price - level.anchor_p) * ratio + + +def timeframe_index_at( + times: list[int], t: int, tf: Timeframe, *, allow_future: bool = False, +) -> float | None: + """Position `t` in a timeframe's own logical bar space. + + Unlike ``index_at``, this never extrapolates backward from a truncated + window. Within a real source bucket it advances by that bucket's normal + duration, so the final minutes before a weekend do not get divided by the + entire weekend gap. + """ + if not times: + return None + upper = bisect_right(times, t) + if upper and times[upper - 1] == t: + return float(upper - 1) + lower = upper - 1 + if lower < 0: + return None + duration = bucket_duration(times[lower], tf) + elapsed = t - times[lower] + if duration <= 0 or elapsed < 0: + return None + if elapsed > duration and not (allow_future and lower == len(times) - 1): + return None + return lower + elapsed / duration + + +def price_in_timeframe_space( + level, times: list[int], tf: Timeframe, t: int, +) -> float | None: + """Price a line in the bar space of the timeframe it belongs to.""" + # Endpoints may deliberately sit in the projection area. They use the same + # repeated-source-bucket approximation as the browser; the live evaluation + # instant itself must still belong to held source history. + start_index = timeframe_index_at(times, level.anchor_t, tf, allow_future=True) + end_index = timeframe_index_at(times, level.last_t, tf, allow_future=True) + target_index = timeframe_index_at(times, t, tf) + if start_index is None or end_index is None or target_index is None: + return None + if end_index == start_index: + return level.anchor_p + end_price = level.anchor_p + level.slope * (level.last_t - level.anchor_t) + ratio = (target_index - start_index) / (end_index - start_index) + return level.anchor_p + (end_price - level.anchor_p) * ratio diff --git a/app/analysis/confluence.py b/app/analysis/confluence.py index f0af2c3..6b5d17e 100644 --- a/app/analysis/confluence.py +++ b/app/analysis/confluence.py @@ -41,7 +41,9 @@ def cluster_levels( positioned = [ (level.current_p if level.current_p is not None else level.price_at(current_t), level) for level in levels - if not level.hidden and (level.cutoff_t is None or current_t <= level.cutoff_t) + if level.geometry_resolved + and not level.hidden + and (level.cutoff_t is None or current_t <= level.cutoff_t) ] for positional_side in (Side.SUPPORT, Side.RESISTANCE): side_levels = sorted( diff --git a/app/analysis/levels.py b/app/analysis/levels.py index e5d6f66..c2e6cf4 100644 --- a/app/analysis/levels.py +++ b/app/analysis/levels.py @@ -51,6 +51,10 @@ class Level: # Optional fixed distance for a hand-placed level. None keeps the global # ATR-based trigger; a value alerts that many points before the level. alert_early_points: float | None = None + # False means the attributed timeframe does not hold enough history to + # price this line safely. Such a line remains visible but cannot cluster or + # alert using the absolute-time fallback. + geometry_resolved: bool = True def price_at(self, t: int) -> float: return self.anchor_p + self.slope * (t - self.anchor_t) diff --git a/app/analysis/manual_lines.py b/app/analysis/manual_lines.py index 0c27a3f..23510ce 100644 --- a/app/analysis/manual_lines.py +++ b/app/analysis/manual_lines.py @@ -34,6 +34,9 @@ class ManualLine: collapsed: bool = False icon: str = "" alert_early_points: float | None = None + # Visual multiplier for marks. 1 is the original 30px glyph; the pin stays + # on (anchor_t, anchor_p) regardless of this value. + scale: float = 1.0 @property def drawing_kind(self) -> str: @@ -122,6 +125,7 @@ class ManualLine: float(value["alert_early_points"]) if value.get("alert_early_points") is not None else None ), + scale=float(value.get("scale", 1.0) or 1.0), ) diff --git a/app/api/routes.py b/app/api/routes.py index 5f12bf5..a4ab505 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -23,6 +23,10 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api", dependencies=[Depends(require_token)]) +def positioned_level(runtime, line_id: str): + return next(level for level in runtime.levels if level.id == line_id) + + @router.post("/debug/captures", status_code=201) async def create_debug_capture(request: Request): try: @@ -107,6 +111,7 @@ class LinePatch(BaseModel): y: float | None = Field(None, ge=0.0, le=1.0) collapsed: bool | None = None alert_early_points: float | None = Field(None, ge=0) + scale: float | None = Field(None, ge=0.5, le=3) @router.get("/status") @@ -119,6 +124,9 @@ def status(request: Request): "symbol": runtime.stream.symbol, "last_bar_t": runtime.stream.last_bar_t, "bars_held": runtime.store.counts(), + "trendline_geometry": ( + "source_tf" if runtime.settings.trendline_source_geometry else "legacy" + ), "warm": {tf.value: bool(runtime.store.get(tf)) for tf in Timeframe}, # How late the event loop is running. Rising numbers mean something is # blocking it — see docs/async_refactor.md. @@ -183,7 +191,7 @@ def create_line(request: Request, payload: LineCreate): runtime = request.app.state.runtime line = runtime.manual_lines.add(line) runtime.rebuild_levels() - return line.to_level().to_dict() + return positioned_level(runtime, line.id).to_dict() @router.post("/lines/price", status_code=201) @@ -210,7 +218,7 @@ def create_price_alert(request: Request, payload: PriceAlertCreate): ) line = runtime.manual_lines.add(line) runtime.rebuild_levels() - return line.to_level().to_dict() + return positioned_level(runtime, line.id).to_dict() class CommentCreate(BaseModel): @@ -228,6 +236,7 @@ class CommentCreate(BaseModel): "skull", "face-laugh-squint", "champagne-glasses", "arrow-left", "arrow-right", "hand", "right-to-bracket", "play", ] | None = None + scale: float = Field(1.0, ge=0.5, le=3) @router.post("/comments", status_code=201) @@ -252,6 +261,7 @@ def create_comment(request: Request, payload: CommentCreate): color=payload.color, kind="symbol" if payload.icon else "comment", icon=payload.icon or "", + scale=payload.scale, pinned=payload.pinned, x=payload.x, y=payload.y, @@ -348,7 +358,9 @@ def patch_line(request: Request, line_id: str, payload: LinePatch): request.app.state.runtime.rebuild_levels() # A comment has no level form — returning one would hand the caller a shape # that looks like something the confluence engine tracks. - return line.to_dict() if line.is_comment else line.to_level().to_dict() + return line.to_dict() if line.is_comment else positioned_level( + request.app.state.runtime, line.id, + ).to_dict() @router.delete("/lines/{line_id}", status_code=204) diff --git a/app/api/ws.py b/app/api/ws.py index f0be5b6..fda3e95 100644 --- a/app/api/ws.py +++ b/app/api/ws.py @@ -4,7 +4,9 @@ from urllib.parse import urlsplit from fastapi import APIRouter, WebSocket, WebSocketDisconnect from app.api.deps import SESSION_COOKIE, session_matches, token_matches +from app.analysis.levels import LevelKind from app.bars.models import Timeframe +from app.bars.session import bucket_duration from app.analysis.confluence import cluster_levels router = APIRouter() @@ -51,6 +53,28 @@ def session_open(runtime) -> float | None: return bars[-1].o if bars else None +def trendline_timeframes(runtime) -> set[Timeframe]: + return { + level.tf for level in runtime.levels + if level.kind is LevelKind.MANUAL and level.slope + } + + +def trendline_geometry(runtime) -> dict: + if not runtime.settings.trendline_source_geometry: + return {"mode": "legacy", "series": {}} + series = {} + for tf in sorted(trendline_timeframes(runtime), key=lambda value: value.value): + times = [bar.t for bar in runtime.store.get(tf)] + value = {"times": times} + if tf is Timeframe.D1: + value["durations"] = [bucket_duration(t, tf) for t in times] + else: + value["duration"] = tf.seconds + series[tf.value] = value + return {"mode": "source_tf", "series": series} + + def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: return { "type": "snapshot", @@ -62,6 +86,7 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict: if runtime.store.get(Timeframe.M1, 1) else None, "session_open": session_open(runtime), + "trendline_geometry": trendline_geometry(runtime), } @@ -84,6 +109,7 @@ async def websocket_endpoint(websocket: WebSocket): tf = Timeframe.M1 prefs = None await websocket.send_json(snapshot(runtime, tf, prefs)) + geometry_tfs = trendline_timeframes(runtime) async def receive(): nonlocal tf, prefs @@ -113,18 +139,46 @@ async def websocket_endpoint(websocket: WebSocket): event = await queue.get() if event["type"] == "disconnect": break - if event["type"] == "bar" and event["bar"].tf is tf: - await websocket.send_json( - { - "type": "bar", "tf": tf.value, - "bar": event["bar"].to_dict(), - "session_open": session_open(runtime), - } - ) + if event["type"] == "bar": + bar = event["bar"] + source_tfs = trendline_timeframes(runtime) + source_bars = runtime.store.get(bar.tf) + first_source_t = source_bars[0].t if source_bars else None + if bar.tf is tf: + await websocket.send_json( + { + "type": "bar", "tf": tf.value, + "bar": bar.to_dict(), + "session_open": session_open(runtime), + "trendline_first_t": ( + first_source_t + if runtime.settings.trendline_source_geometry + and bar.tf in source_tfs else None + ), + } + ) + elif ( + runtime.settings.trendline_source_geometry + and bar.tf in source_tfs + ): + await websocket.send_json( + { + "type": "trendline_bar", + "tf": bar.tf.value, + "t": bar.t, + "duration": bucket_duration(bar.t, bar.tf), + "first_t": first_source_t, + } + ) elif event["type"] == "levels": - await websocket.send_json( - {"type": "levels", "changed": event["changed"], "removed": event["removed"]} - ) + message = { + "type": "levels", "changed": event["changed"], "removed": event["removed"], + } + current_tfs = trendline_timeframes(runtime) + if current_tfs != geometry_tfs: + message["trendline_geometry"] = trendline_geometry(runtime) + geometry_tfs = current_tfs + await websocket.send_json(message) elif event["type"] == "clusters": clusters = connection_clusters(runtime, prefs) await websocket.send_json( diff --git a/app/bars/session.py b/app/bars/session.py index 7e51333..6a9683b 100644 --- a/app/bars/session.py +++ b/app/bars/session.py @@ -6,6 +6,7 @@ from app.bars.models import Timeframe UTC = ZoneInfo("UTC") EASTERN = ZoneInfo("America/New_York") SESSION_OPEN = time(18, 0) +SESSION_CLOSE = time(17, 0) def _session_open_local(current: datetime) -> datetime: @@ -22,3 +23,17 @@ def bucket_start(t: int, tf: Timeframe) -> int: current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN) return int(_session_open_local(current).timestamp()) + + +def bucket_duration(t: int, tf: Timeframe) -> int: + """Wall-clock span of one logical bar bucket. + + Intraday buckets are fixed. A daily line advances only through the active + 18:00-17:00 ET session, not the settlement halt; constructing the close in + Eastern keeps DST transitions correct without shifting stored UTC times. + """ + if tf is not Timeframe.D1: + return tf.seconds + current = datetime.fromtimestamp(t, UTC).astimezone(EASTERN) + next_close = datetime.combine(current.date() + timedelta(days=1), SESSION_CLOSE, EASTERN) + return int(next_close.timestamp()) - t diff --git a/app/config.py b/app/config.py index 918de97..9350662 100644 --- a/app/config.py +++ b/app/config.py @@ -30,6 +30,9 @@ class Settings(BaseSettings): timeframes: str = "1m,5m,15m,30m,1h,1d" base_timeframes: str = "1m,30m,1d" max_bars_per_tf: int = 5000 + # Rollback gate for source-timeframe trendline geometry. When false, both + # server pricing and browser rendering use the previous 1m/displayed grid. + trendline_source_geometry: bool = True ma_sets__1d: str = "sma10,sma20,sma50,sma100,sma200" daily_anchor_et: str = "18:00" manual_lines_path: Path = Path("./data/manual_lines.json") diff --git a/app/runtime.py b/app/runtime.py index a00d6e6..f4ee69b 100644 --- a/app/runtime.py +++ b/app/runtime.py @@ -8,7 +8,7 @@ from app.analysis.alerts import Alert, AlertEngine from app.bars.models import Bar, Timeframe from app.bars.aggregator import Aggregator from app.bars.session import bucket_start -from app.analysis.bar_space import price_in_bar_space +from app.analysis.bar_space import price_in_bar_space, price_in_timeframe_space from app.analysis.horizontals import build_prior_day_levels from app.analysis.levels import Level from app.analysis.moving_averages import build_ma_levels @@ -200,8 +200,7 @@ class Runtime: self.broadcast_level_delta() self.rebuild_clusters() - @staticmethod - def position_manual_levels(levels: list[Level], bars: list[Bar]) -> None: + def position_manual_levels(self, levels: list[Level], bars: list[Bar]) -> None: """Price sloped lines across bars rather than seconds. The chart spaces bars evenly, so the line a person drew advances per bar. @@ -210,11 +209,17 @@ class Runtime: """ if not bars: return - times = [bar.t for bar in bars] - now = times[-1] + minute_times = [bar.t for bar in bars] + now = minute_times[-1] for level in levels: - if level.slope: - level.current_p = price_in_bar_space(level, times, now) + if not level.slope: + continue + if not self.settings.trendline_source_geometry: + level.current_p = price_in_bar_space(level, minute_times, now) + continue + source_times = [bar.t for bar in self.store.get(level.tf)] + level.current_p = price_in_timeframe_space(level, source_times, level.tf, now) + level.geometry_resolved = level.current_p is not None def broadcast_level_delta(self) -> None: """Send only levels whose serialised form actually changed. diff --git a/docs/implementation.md b/docs/implementation.md index d482f2d..ad23a17 100644 --- a/docs/implementation.md +++ b/docs/implementation.md @@ -922,3 +922,84 @@ consecutive bars, all aligned, all sixty seconds apart. Worth generalising: a measurement that looks absurd is worth chasing rather than clamping. The absurd number here was "67 bars of padding", and the bug behind it had nothing to do with padding. + +### 2026-08-14 — trendlineproblem: high-TF lines used the wrong bar grid + +**Confirmed and fixed behind `TRENDLINE_SOURCE_GEOMETRY`.** No drawing records +were migrated or rewritten. + +The original diagnosis mixed one real defect with one representational detail. +A 30m candle being pinned at its bucket-open timestamp is normal: that timestamp +identifies the aggregate candle, and the exact 1m that printed its extreme is +unavailable for native 30m history older than the minute seed. It remains a +separate product choice, not part of this fix. + +The real defect was that the line's coordinate system depended on its consumer. +The browser used the displayed `this.bars`, limited to the 1,000 bars in its +WebSocket snapshot. The server used every stored 1m bar, up to 5,000. Neither +used the line's attributed timeframe. Complete nested 30m/1m windows often hide +this because their indexes differ by a constant factor. Once anchors predate +the shorter window, edge extrapolation counts closed-market clock time as +fictional 1m bars and changes the projected slope. + +Measured against the five persisted 30m drawings before changing code, the +source-30m price minus the browser's 1m-window price at the live edge was: + +``` +#9 +175.5 points +#10 -6.5 points +#12 -1.1 points +#13 +1.2 points +#14 -11.1 points +``` + +This was also an alert mismatch: `Runtime.position_manual_levels` priced every +manual line in the server's 1m store, so the chart, the drawn timeframe and the +phone could each be using different geometry. + +**Canonical geometry now belongs to the line's `tf`.** Persisted +`anchor_t`/`anchor_p` and `last_t` still identify the endpoints; `slope` +recovers the second endpoint price. `timeframe_index_at` resolves those times in +the source bar sequence and advances fractionally through a real source bucket. +It refuses to invent history before the first source bar. Daily fractional +progress uses the active 18:00-17:00 ET session, so the settlement halt adds no +slope; the boundary is constructed in Eastern time for DST correctness. + +WebSocket snapshots carry timestamp-only source series for the timeframes used +by sloped manual lines, plus live timestamp updates. A levels delta sends a full +replacement when another browser introduces a new source timeframe, and each +bar carries the server ring buffer's first timestamp so long-lived tabs trim +evicted history too. The browser evaluates the same source-space line and +samples it only onto displayed candle timestamps, so +manual lines cannot add points to Lightweight Charts' shared scale. Historical +series, future projection, hit testing and the selected-line hit polyline all +call that price function. Whole-line dragging, horizontal keyboard nudging and +duplication shift timestamps in source bars rather than whatever timeframe is +currently displayed. Endpoint dragging can still choose a finer-timeframe +instant; it becomes a fractional coordinate in the source bar. Future endpoints +use the same repeated-source-bucket approximation on both sides; the separate, +pre-existing limitation that future axis times are not session-aware remains. + +If source history no longer reaches an anchor, `geometry_resolved` is false. +The drawing remains available, but confluence and alerts exclude it instead of +falling back to per-second pricing. That is safer than issuing a push at an +invented price. Source-dependent whole-line movement, nudging and duplication +are also disabled until the line resolves. + +**Rollback is one setting.** Set `TRENDLINE_SOURCE_GEOMETRY=false` and restart. +The server restores legacy 1m pricing, the snapshot tells the browser to restore +displayed-grid rendering and movement, and persisted drawings remain untouched. +`GET /api/status` reports `trendline_geometry` as `source_tf` or `legacy`. + +Verification: focused backend regressions cover off-window anchors, equivalent +overlapping grids, a partial 30m bar before a weekend, missing source history, +future endpoints and the daily settlement halt. Runtime and snapshot tests cover +source pricing, unresolved lines and the rollback payload. A deterministic +browser test proves a 30m line with both anchors outside the 1m window prices +correctly, samples only displayed timestamps, nudges by a 30m source bar, and +returns to the old wrong result when rollback mode is selected. It also proves +that evicted source timestamps are trimmed. +Full result: 148 backend tests passed; 31 browser +tests passed with 9 previously quarantined tests skipped. Live Chromium then +showed active persisted lines #12, #13 and #14 with a zero-point delta between +their final rendered sample and server `current_p`, and no console errors. diff --git a/docs/plan.md b/docs/plan.md index 0f4db7a..3544fd9 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -596,10 +596,18 @@ definition* (you drew them). So they validate the confluence engine without the automatic detector's tuning risk, and they later become the ground truth that M8's scoring coefficients get tuned against. -**Geometry.** Anchors are stored in **absolute epoch seconds and price** — never bar -indices. This is why a line drawn on the 4h chart renders correctly on the 1m chart -with no conversion: both are the same `(time, price)` plane. The existing -`Level.price_at(t)` already handles it. +**Geometry.** Anchors are persisted as **absolute epoch seconds and price** — never +bar indices — so changing the algorithm does not rewrite drawings. A sloped line is +evaluated in the logical bar space of its attributed timeframe, however: charts +compress a weekend to one slot, so wall-clock `price_at(t)` would advance through 49 +hours in which no bars exist. The stored `slope` recovers the second endpoint price; +the source-timeframe bar sequence determines interpolation and projection. The +browser samples that canonical geometry only at displayed candle timestamps, and the +server uses the same source series for clusters and alerts. A source history that no +longer reaches an anchor leaves the drawing visible but unresolved and unable to +cluster or alert rather than silently extrapolating from the edge of a shorter +window. `TRENDLINE_SOURCE_GEOMETRY=false` restores the previous displayed/1m-grid +behavior without changing persisted data. **Timeframe attribution.** Tag the line with the timeframe that was *displayed when it was drawn*. A line drawn on the 4h chart is a 4h line and carries weight 8. This is the @@ -616,10 +624,10 @@ scored. `weight = TIMEFRAME_WEIGHT[tf]`. | Place endpoints | `chart.subscribeClick(handler)` → two clicks | | Pixel → price | `series.coordinateToPrice(param.point.y)` | | Pixel → time | `chart.timeScale().coordinateToTime(param.point.x)` | -| Render | `LineSeries` with 2 points, extended right (same as §9 auto lines) | -| Select | Click within ~6px of a line — hit-test in price space via `price_at(t)` | +| Render | Sample source-timeframe geometry onto displayed candles; extend right in an SVG overlay | +| Select | Click within ~6px of the canonical price at that displayed bar | | Delete | `Delete`/`Backspace` on selection, plus a button | -| Edit | **Delete and redraw.** Endpoint dragging is real work — do not build it in M5 | +| Edit | Endpoint handles, whole-line drag, keyboard nudge, cutoff and duplicate; time shifts use source bars | **Snapping.** When placing an endpoint, snap to the nearest bar high/low within ~8px. Cheap to implement and it is the difference between a usable drawing tool and a @@ -1015,6 +1023,7 @@ SCHWAB_SYMBOL=/ES TIMEFRAMES=1m,2m,5m,15m,30m,1h,1d BASE_TIMEFRAMES=1m,30m,1d # the chart switcher MAX_BARS_PER_TF=5000 # in-memory ring buffer bound +TRENDLINE_SOURCE_GEOMETRY=true # false = rollback to displayed/1m-grid pricing # Daily MA set is the primary requirement; others ship disabled. See §7.4 MA_SETS__1D=sma10,sma20,sma50,sma100,sma200 diff --git a/static/app.js b/static/app.js index 0a04527..3865ef5 100644 --- a/static/app.js +++ b/static/app.js @@ -123,6 +123,8 @@ createApp({ const commentFloat = ref(false); const selectedSymbol = ref('arrow-up'); const symbolColor = ref('#65b7cf'); + const symbolScale = ref(1); + const symbolScales = [0.5, 1, 1.5, 2, 3]; const symbolPanelOpen = ref(false); // Comments are not levels, so they never arrive over the levels socket and // are fetched and refreshed explicitly. @@ -286,6 +288,7 @@ createApp({ const message = JSON.parse(data); if (message.type === 'snapshot') { dataReceivedAt.value = Date.now(); + chartApi.setTrendlineGeometry(message.trendline_geometry); chartApi.setBars(message.bars); levels.value = message.levels || []; if (!drawColorInitialized) { @@ -306,10 +309,16 @@ createApp({ // outright. The event carries its timeframe, so honour it. if (message.tf && message.tf !== timeframe.value) return; dataReceivedAt.value = Date.now(); + chartApi.updateTrendlineTime( + message.bar.tf, message.bar.t, null, message.trendline_first_t, + ); chartApi.updateBar(message.bar); price.value = message.bar.c; sessionOpen.value = message.session_open ?? sessionOpen.value; status.value.last_bar_t = message.bar.t; + } else if (message.type === 'trendline_bar') { + chartApi.updateTrendlineTime(message.tf, message.t, message.duration, message.first_t); + syncVisibleLevels(); } else if (message.type === 'levels') { // A delta, not a replacement: VWAP changes every minute while the // daily averages carry hundreds of points and change once a session. @@ -317,6 +326,7 @@ createApp({ const byId = new Map(levels.value.filter(level => !removed.has(level.id)).map(level => [level.id, level])); for (const level of message.changed || []) byId.set(level.id, level); levels.value = [...byId.values()]; + if (message.trendline_geometry) chartApi.setTrendlineGeometry(message.trendline_geometry); syncVisibleLevels(); } else if (message.type === 'clusters') { clusters.value = message.clusters; @@ -519,7 +529,7 @@ createApp({ const response = await apiFetch('/api/comments', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - text: choice.name, icon, color: symbolColor.value, pinned: true, + text: choice.name, icon, color: symbolColor.value, scale: symbolScale.value, pinned: true, anchor_t: result.time, anchor_p: result.price, x: result.x, y: result.y, }), }); @@ -649,18 +659,8 @@ createApp({ await deleteLines([id]); } - function shiftTimeByBars(time, count = 10) { - const bars = chartApi?.bars || []; - if (bars.length < 2) return time; - let nearest = 0; - for (let index = 1; index < bars.length; index += 1) { - if (Math.abs(bars[index].t - time) < Math.abs(bars[nearest].t - time)) nearest = index; - } - const target = nearest + count; - if (target < bars.length) return bars[target].t; - const last = bars.length - 1; - const step = bars[last].t - bars[last - 1].t; - return bars[last].t + (target - last) * step; + function shiftTimeByBars(line, time, count = 10) { + return chartApi?.shiftLineTime(line, time, count) ?? time; } function duplicateName(line) { @@ -673,8 +673,9 @@ createApp({ } async function duplicateLine(line) { - const anchorT = shiftTimeByBars(line.anchor_t); - const endT = shiftTimeByBars(line.last_t); + if (line.geometry_resolved === false) return; + const anchorT = shiftTimeByBars(line, line.anchor_t); + const endT = shiftTimeByBars(line, line.last_t); const endP = line.anchor_p + line.slope * (line.last_t - line.anchor_t); const response = await apiFetch('/api/lines', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -684,7 +685,7 @@ createApp({ end_t: endT, end_p: endP, note: duplicateName(line), hidden: line.hidden, color: line.color || '#65b7cf', line_width: line.line_width || 2, - cutoff_t: line.cutoff_t == null ? null : shiftTimeByBars(line.cutoff_t), + cutoff_t: line.cutoff_t == null ? null : shiftTimeByBars(line, line.cutoff_t), armed: line.armed, }), }); @@ -823,10 +824,11 @@ createApp({ + item.line.slope * (item.line.last_t - item.line.anchor_t) + vertical * ConfluenceChart.TICK; if (horizontal) { - line.anchor_t = chartApi.timeAtIndex(chartApi.indexAt(line.anchor_t) + horizontal); - line.last_t = chartApi.timeAtIndex(chartApi.indexAt(line.last_t) + horizontal); + if (line.geometry_resolved === false) continue; + line.anchor_t = chartApi.shiftLineTime(line, line.anchor_t, horizontal); + line.last_t = chartApi.shiftLineTime(line, line.last_t, horizontal); if (line.cutoff_t != null) { - line.cutoff_t = chartApi.timeAtIndex(chartApi.indexAt(line.cutoff_t) + horizontal); + line.cutoff_t = chartApi.shiftLineTime(line, line.cutoff_t, horizontal); } } line.slope = (endPrice - line.anchor_p) / (line.last_t - line.anchor_t); @@ -1013,6 +1015,6 @@ createApp({ window.removeEventListener('keydown', handleKeydown); }); - return { status, price, sessionOpen, quoteChange, animateCurrentPrice, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol }; + return { status, price, sessionOpen, quoteChange, animateCurrentPrice, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolScale, symbolScales, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol }; }, }).mount('#app'); diff --git a/static/chart.js b/static/chart.js index b3dc33d..84234a4 100644 --- a/static/chart.js +++ b/static/chart.js @@ -15,6 +15,8 @@ class ConfluenceChart { this.projectionLayer = null; this.bars = []; this.levels = []; + this.trendlineGeometryMode = 'legacy'; + this.trendlineSourceSeries = new Map(); this.onChartClick = null; this.tooltip = null; this.chartEl = null; @@ -278,7 +280,7 @@ class ConfluenceChart { this.lineFocus.classList.add('chart-line-focus'); this.lineFocus.setAttribute('hidden', ''); handles.appendChild(this.lineFocus); - this.lineHitTarget = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + this.lineHitTarget = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); this.lineHitTarget.classList.add('chart-line-hit'); this.lineHitTarget.setAttribute('hidden', ''); this.lineHitTarget.addEventListener('pointerdown', event => this.startLineDrag(event)); @@ -438,6 +440,43 @@ class ConfluenceChart { this.renderTrendlineProjections(); } + setTrendlineGeometry(geometry) { + this.trendlineGeometryMode = geometry?.mode === 'source_tf' ? 'source_tf' : 'legacy'; + this.trendlineSourceSeries = new Map( + Object.entries(geometry?.series || {}).map(([tf, value]) => [tf, { + times: (value.times || []).map(Number), + duration: value.duration == null ? null : Number(value.duration), + durations: (value.durations || []).map(Number), + }]), + ); + } + + updateTrendlineTime(tf, time, duration = null, firstTime = null) { + if (this.trendlineGeometryMode !== 'source_tf') return; + let source = this.trendlineSourceSeries.get(tf); + if (!source) { + source = { times: [], duration: duration == null ? null : Number(duration), durations: [] }; + this.trendlineSourceSeries.set(tf, source); + } + if (firstTime != null) { + const first = Number(firstTime); + const remove = source.times.findIndex(value => value >= first); + const count = remove < 0 ? source.times.length : remove; + if (count > 0) { + source.times.splice(0, count); + if (source.duration == null) source.durations.splice(0, count); + } + } + const t = Number(time); + const last = source.times.length - 1; + if (last < 0 || t > source.times[last]) { + source.times.push(t); + if (source.duration == null) source.durations.push(Number(duration)); + } else if (t === source.times[last] && source.duration == null && duration != null) { + source.durations[last] = Number(duration); + } + } + /** * Empty slots past the last bar, so the axis can label the projection area. * @@ -662,8 +701,10 @@ class ConfluenceChart { if (iconClass) { node.icon.className = `fa-solid ${iconClass}`; node.label.replaceChildren(node.icon); + node.root.style.setProperty('--mark-scale', String(comment.scale || 1)); } else { node.label.textContent = comment.collapsed ? String(comment.number) : comment.note; + node.root.style.removeProperty('--mark-scale'); } node.root.style.borderColor = comment.color; node.root.style.setProperty('--comment-focus-color', comment.color || '#c8992f'); @@ -685,9 +726,19 @@ class ConfluenceChart { const offRight = placed.after || (x !== null && x > width); node.root.classList.toggle('off-left', offLeft && !offRight); node.root.classList.toggle('off-right', offRight); - const clampedX = offLeft ? 4 : offRight ? width - node.root.offsetWidth - 4 : x; - node.root.style.left = `${Math.round(clampedX)}px`; - node.root.style.top = `${Math.round(Math.min(Math.max(y == null ? 24 : y, 4), height - 28))}px`; + const boxW = node.root.offsetWidth; + const boxH = node.root.offsetHeight; + if (iconClass) { + const rawX = x == null ? 4 : x - boxW / 2; + const rawY = y == null ? 24 : y - boxH / 2; + const clampedX = offLeft ? 4 : offRight ? width - boxW - 4 : rawX; + node.root.style.left = `${Math.round(clampedX)}px`; + node.root.style.top = `${Math.round(Math.min(Math.max(rawY, 4), height - boxH - 4))}px`; + } else { + const clampedX = offLeft ? 4 : offRight ? width - boxW - 4 : x; + node.root.style.left = `${Math.round(clampedX)}px`; + node.root.style.top = `${Math.round(Math.min(Math.max(y == null ? 24 : y, 4), height - 28))}px`; + } } for (const [id, node] of this.commentNodes) { if (!seen.has(id)) { @@ -1543,18 +1594,11 @@ class ConfluenceChart { const startIndex = Math.max(this.indexAt(level.anchor_t), visible.from); const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t); const endIndex = Math.min(naturalEnd, visible.to); - if (endIndex <= startIndex) continue; - const start = { - x: this.coordinateAtIndex(startIndex), - y: this.candles.priceToCoordinate(this.linePriceAtIndex(level, startIndex)), - }; - const end = { - x: this.coordinateAtIndex(endIndex), - y: this.candles.priceToCoordinate(this.linePriceAtIndex(level, endIndex)), - }; - if ([start.x, start.y, end.x, end.y].some(value => value == null) || start.x === end.x) continue; - if (param.point.x < Math.min(start.x, end.x) - 6 || param.point.x > Math.max(start.x, end.x) + 6) continue; - const lineY = start.y + (end.y - start.y) * (param.point.x - start.x) / (end.x - start.x); + const pointIndex = this.indexAtCoordinate(param.point.x); + if (pointIndex == null || pointIndex < startIndex || pointIndex > endIndex) continue; + const linePrice = this.linePriceAtIndex(level, pointIndex); + const lineY = linePrice == null ? null : this.candles.priceToCoordinate(linePrice); + if (lineY == null) continue; const distance = Math.abs(lineY - param.point.y); if (distance <= 6 && (!best || distance < best.distance)) best = { id: level.id, distance }; } @@ -1588,6 +1632,72 @@ class ConfluenceChart { return lo + (span ? (time - bars[lo].t) / span : 0); } + sourceSeries(level) { + if (this.trendlineGeometryMode !== 'source_tf') return null; + const source = this.trendlineSourceSeries.get(level.tf); + if (source) return source; + if (this.bars[0]?.tf !== level.tf) return null; + const duration = ConfluenceChart.tfSeconds[level.tf] || null; + return { + times: this.bars.map(bar => bar.t), + duration, + durations: duration == null + ? this.bars.map((bar, index, values) => values[index + 1]?.t - bar.t || 86400) + : [], + }; + } + + static sourceDuration(source, index) { + return source.duration ?? source.durations[index] ?? source.durations[source.durations.length - 1] ?? null; + } + + static timeframeIndexAt(source, time, allowFuture = true) { + const times = source?.times || []; + if (!times.length) return null; + let lo = 0; + let hi = times.length; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (times[mid] <= time) lo = mid + 1; else hi = mid; + } + const lower = lo - 1; + if (lower >= 0 && times[lower] === time) return lower; + if (lower < 0) return null; + const duration = ConfluenceChart.sourceDuration(source, lower); + const elapsed = time - times[lower]; + if (!(duration > 0) || elapsed < 0) return null; + if (elapsed > duration && !(allowFuture && lower === times.length - 1)) return null; + return lower + elapsed / duration; + } + + static timeframeTimeAt(source, index) { + const times = source?.times || []; + if (!times.length || index < 0) return null; + const lower = Math.min(Math.floor(index), times.length - 1); + const duration = ConfluenceChart.sourceDuration(source, lower); + if (!(duration > 0)) return null; + return Math.round(times[lower] + (index - lower) * duration); + } + + sourceLinePriceAt(level, time) { + const source = this.sourceSeries(level); + if (!source) return null; + const start = ConfluenceChart.timeframeIndexAt(source, level.anchor_t); + const end = ConfluenceChart.timeframeIndexAt(source, level.last_t); + const target = ConfluenceChart.timeframeIndexAt(source, time); + if (start == null || end == null || target == null) return null; + if (start === end) return level.anchor_p; + const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t); + return level.anchor_p + (endPrice - level.anchor_p) * (target - start) / (end - start); + } + + shiftLineTime(level, time, bars) { + const source = this.sourceSeries(level); + if (!source) return this.timeAtIndex(this.indexAt(time) + bars); + const index = ConfluenceChart.timeframeIndexAt(source, time); + return index == null ? time : ConfluenceChart.timeframeTimeAt(source, index + bars) ?? time; + } + timeAtIndex(index) { const bars = this.bars; if (bars.length < 2) return bars[0]?.t ?? 0; @@ -1610,6 +1720,9 @@ class ConfluenceChart { } linePriceAtIndex(level, index) { + if (this.trendlineGeometryMode === 'source_tf') { + return this.sourceLinePriceAt(level, this.timeAtIndex(index)); + } const start = this.indexAt(level.anchor_t); const end = this.indexAt(level.last_t); const endPrice = level.anchor_p + level.slope * (level.last_t - level.anchor_t); @@ -1620,6 +1733,13 @@ class ConfluenceChart { seriesLineData(level) { if (!this.bars.length) return []; + if (this.trendlineGeometryMode === 'source_tf') { + const cutoff = level.cutoff_t ?? Infinity; + return this.bars + .filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff) + .map(bar => ({ time: bar.t, value: this.sourceLinePriceAt(level, bar.t) })) + .filter(point => point.value != null); + } const start = this.indexAt(level.anchor_t); const lastBar = this.bars.length - 1; const cutoff = level.cutoff_t == null ? lastBar : this.indexAt(level.cutoff_t); @@ -1640,6 +1760,22 @@ class ConfluenceChart { const visible = this.visibleBarIndexes(); if (!visible) return; const lastBar = this.bars.length - 1; + const segment = (level, t1, p1, t2, p2) => { + const x1 = this.coordinateAtTime(t1); + const x2 = this.coordinateAtTime(t2); + const y1 = p1 == null ? null : this.candles.priceToCoordinate(p1); + const y2 = p2 == null ? null : this.candles.priceToCoordinate(p2); + if ([x1, x2, y1, y2].some(value => value == null)) return null; + const node = document.createElementNS('http://www.w3.org/2000/svg', 'line'); + node.dataset.lineId = level.id; + node.setAttribute('x1', x1); + node.setAttribute('y1', y1); + node.setAttribute('x2', x2); + node.setAttribute('y2', y2); + node.setAttribute('stroke', ConfluenceChart.levelColor(level)); + node.setAttribute('stroke-width', level.line_width || 2); + return node; + }; const lines = this.levels .filter(level => level.kind === 'manual' && !level.hidden && !ConfluenceChart.isFlat(level)) .map(level => { @@ -1647,22 +1783,50 @@ class ConfluenceChart { const naturalEnd = level.cutoff_t == null ? visible.to : this.indexAt(level.cutoff_t); const end = Math.min(naturalEnd, visible.to); if (end <= start) return null; - const x1 = this.coordinateAtIndex(start); - const x2 = this.coordinateAtIndex(end); - const y1 = this.candles.priceToCoordinate(this.linePriceAtIndex(level, start)); - const y2 = this.candles.priceToCoordinate(this.linePriceAtIndex(level, end)); - if ([x1, x2, y1, y2].some(value => value == null)) return null; - const node = document.createElementNS('http://www.w3.org/2000/svg', 'line'); - node.dataset.lineId = level.id; - node.setAttribute('x1', x1); - node.setAttribute('y1', y1); - node.setAttribute('x2', x2); - node.setAttribute('y2', y2); - node.setAttribute('stroke', ConfluenceChart.levelColor(level)); - node.setAttribute('stroke-width', level.line_width || 2); - return node; + const t1 = this.timeAtIndex(start); + const t2 = this.timeAtIndex(end); + const p1 = this.linePriceAtIndex(level, start); + const p2 = this.linePriceAtIndex(level, end); + return segment(level, t1, p1, t2, p2); }) .filter(Boolean); + if (this.trendlineGeometryMode === 'source_tf') { + const firstDisplayed = this.bars[0].t; + const lastDisplayed = this.bars[lastBar].t; + for (const level of this.levels.filter(value => + value.kind === 'manual' && !value.hidden && !ConfluenceChart.isFlat(value))) { + const cutoff = level.cutoff_t ?? Infinity; + const sampled = this.bars.filter(bar => bar.t >= level.anchor_t && bar.t <= cutoff + && this.sourceLinePriceAt(level, bar.t) != null); + if (!sampled.length) { + if (Number.isFinite(cutoff) + && level.anchor_t >= firstDisplayed && cutoff <= lastDisplayed) { + const node = segment( + level, level.anchor_t, level.anchor_p, + cutoff, this.sourceLinePriceAt(level, cutoff), + ); + if (node) lines.push(node); + } + continue; + } + const first = sampled[0]; + if (first.t > level.anchor_t && level.anchor_t >= firstDisplayed) { + const node = segment( + level, level.anchor_t, level.anchor_p, + first.t, this.sourceLinePriceAt(level, first.t), + ); + if (node) lines.push(node); + } + const last = sampled[sampled.length - 1]; + if (Number.isFinite(cutoff) && last.t < cutoff && cutoff <= lastDisplayed) { + const node = segment( + level, last.t, this.sourceLinePriceAt(level, last.t), + cutoff, this.sourceLinePriceAt(level, cutoff), + ); + if (node) lines.push(node); + } + } + } this.projectionLayer.replaceChildren(...lines); } @@ -1724,19 +1888,34 @@ class ConfluenceChart { ]; const visible = this.visibleBarIndexes(); const hitEnd = level.cutoff_t == null ? visible?.to : this.indexAt(level.cutoff_t); - const hitIndexes = [this.indexAt(level.anchor_t), hitEnd]; + const hitStart = this.indexAt(level.anchor_t); + const hitIndexes = []; + if (hitEnd != null) { + for (let index = Math.max(hitStart, visible?.from ?? hitStart); index <= hitEnd; index += 1) { + hitIndexes.push(index); + } + if (!hitIndexes.length || hitIndexes[hitIndexes.length - 1] < hitEnd) hitIndexes.push(hitEnd); + } const hitPoints = hitIndexes.map(index => ({ x: index == null ? null : this.coordinateAtIndex(index), - y: index == null ? null : this.candles.priceToCoordinate(this.linePriceAtIndex(level, index)), + y: index == null ? null : this.linePriceAtIndex(level, index), + })).map(point => ({ + x: point.x, + y: point.y == null ? null : this.candles.priceToCoordinate(point.y), })); - if (hitPoints.some(point => point.x == null || point.y == null)) { + if (hitPoints.length < 2 || hitPoints.some(point => point.x == null || point.y == null)) { this.lineHitTarget.setAttribute('hidden', ''); } else { this.lineHitTarget.removeAttribute('hidden'); - this.lineHitTarget.setAttribute('x1', hitPoints[0].x); - this.lineHitTarget.setAttribute('y1', hitPoints[0].y); - this.lineHitTarget.setAttribute('x2', hitPoints[1].x); - this.lineHitTarget.setAttribute('y2', hitPoints[1].y); + const first = hitPoints[0]; + const last = hitPoints[hitPoints.length - 1]; + this.lineHitTarget.setAttribute('points', hitPoints.map(point => `${point.x},${point.y}`).join(' ')); + // Kept for diagnostics and the existing drag test, though the polyline's + // actual hit region follows every sampled point. + this.lineHitTarget.setAttribute('x1', first.x); + this.lineHitTarget.setAttribute('y1', first.y); + this.lineHitTarget.setAttribute('x2', last.x); + this.lineHitTarget.setAttribute('y2', last.y); } points.forEach((point, index) => { const x = this.coordinateAtTime(point.time); @@ -1757,7 +1936,7 @@ class ConfluenceChart { if (event.button !== 0) return; const level = this.levels.find(value => value.id === this.selectedLineId); const point = this.eventPoint(event); - if (!level || !point || point.t == null) return; + if (!level || level.geometry_resolved === false || !point || point.t == null) return; event.preventDefault(); event.stopPropagation(); this.draggingLine = { @@ -1776,10 +1955,21 @@ class ConfluenceChart { if (!point || point.t == null || !this.withinPlot(point) || !level) return; event.preventDefault(); const { original, start } = this.draggingLine; - const indexShift = Math.round(this.indexAt(point.t) - this.indexAt(start.t)); + const source = this.sourceSeries(original); + const sourcePoint = source && ConfluenceChart.timeframeIndexAt(source, point.t); + const sourceStart = source && ConfluenceChart.timeframeIndexAt(source, start.t); + const indexShift = Math.round( + sourcePoint != null && sourceStart != null + ? sourcePoint - sourceStart + : this.indexAt(point.t) - this.indexAt(start.t), + ); const priceShift = ConfluenceChart.snapToTick(point.p - start.p); - const anchorT = this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift); - const lastT = this.timeAtIndex(this.indexAt(original.last_t) + indexShift); + const anchorT = source + ? this.shiftLineTime(original, original.anchor_t, indexShift) + : this.timeAtIndex(this.indexAt(original.anchor_t) + indexShift); + const lastT = source + ? this.shiftLineTime(original, original.last_t, indexShift) + : this.timeAtIndex(this.indexAt(original.last_t) + indexShift); const endPrice = original.anchor_p + original.slope * (original.last_t - original.anchor_t) + priceShift; level.anchor_t = anchorT; @@ -1787,7 +1977,9 @@ class ConfluenceChart { level.anchor_p = original.anchor_p + priceShift; level.slope = (endPrice - level.anchor_p) / (lastT - anchorT); if (original.cutoff_t != null) { - level.cutoff_t = this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift); + level.cutoff_t = source + ? this.shiftLineTime(original, original.cutoff_t, indexShift) + : this.timeAtIndex(this.indexAt(original.cutoff_t) + indexShift); } this.draggingLine.moved = indexShift !== 0 || priceShift !== 0; const entry = this.levelSeries.get(level.id); @@ -1952,6 +2144,10 @@ ConfluenceChart.tfColors = { '30m':'#a8c85d', '1h':'#efb643', '1d':'#d96073', }; +ConfluenceChart.tfSeconds = { + '1m': 60, '2m': 120, '5m': 300, '15m': 900, '30m': 1800, '1h': 3600, +}; + // VWAP and the prior-day levels are both stamped 1d, so without their own // colours they would be indistinguishable from the daily moving averages. ConfluenceChart.kindColors = { vwap: '#b07ad6', horizontal: '#9fb0c4' }; diff --git a/static/index.html b/static/index.html index 688d599..418a4fe 100644 --- a/static/index.html +++ b/static/index.html @@ -99,6 +99,9 @@ +
@@ -261,6 +264,11 @@
+