pimco-charts/app/renderer/scale.py
DJP a3a38e85d2 Initial commit: PIMCO chart generator with iterative refinement
AI-powered tool that generates publication-quality SVG charts matching
PIMCO's InDesign style. Upload Excel/CSV data, write a plain-English
brief, then iterate with natural language edits until the chart is
exactly right.

- Claude Opus 4.6 interprets briefs into structured ChartSpec JSON
- Deterministic SVG renderer via drawsvg (no visual hallucinations)
- Roboto/Roboto Condensed fonts base64-embedded in SVG
- FastAPI + HTMX web frontend with live preview
- Conversational refinement: "make lines thicker", "change title", etc.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:29:47 -05:00

122 lines
3.8 KiB
Python

"""Data-to-pixel coordinate mapping."""
from __future__ import annotations
import math
from datetime import datetime
from typing import Sequence
class LinearScale:
"""Maps a numeric data domain to a pixel range."""
def __init__(self, domain_min: float, domain_max: float, range_min: float, range_max: float):
self.domain_min = domain_min
self.domain_max = domain_max
self.range_min = range_min
self.range_max = range_max
span = domain_max - domain_min
self._scale = (range_max - range_min) / span if span != 0 else 0
def __call__(self, value: float) -> float:
return self.range_min + (value - self.domain_min) * self._scale
def invert(self, pixel: float) -> float:
if self._scale == 0:
return self.domain_min
return self.domain_min + (pixel - self.range_min) / self._scale
class DateScale:
"""Maps datetime values to pixel positions."""
def __init__(self, date_min: datetime, date_max: datetime, range_min: float, range_max: float):
self.date_min = date_min
self.date_max = date_max
self.range_min = range_min
self.range_max = range_max
total_seconds = (date_max - date_min).total_seconds()
self._scale = (range_max - range_min) / total_seconds if total_seconds != 0 else 0
def __call__(self, value: datetime) -> float:
seconds = (value - self.date_min).total_seconds()
return self.range_min + seconds * self._scale
def invert(self, pixel: float) -> datetime:
from datetime import timedelta
if self._scale == 0:
return self.date_min
seconds = (pixel - self.range_min) / self._scale
return self.date_min + timedelta(seconds=seconds)
def nice_ticks(data_min: float, data_max: float, target_count: int = 6) -> list[float]:
"""Generate aesthetically pleasing tick values for an axis."""
if data_min == data_max:
return [data_min]
raw_step = (data_max - data_min) / target_count
magnitude = 10 ** math.floor(math.log10(abs(raw_step)))
residual = raw_step / magnitude
if residual <= 1.5:
nice_step = 1 * magnitude
elif residual <= 3:
nice_step = 2 * magnitude
elif residual <= 7:
nice_step = 5 * magnitude
else:
nice_step = 10 * magnitude
tick_min = math.floor(data_min / nice_step) * nice_step
tick_max = math.ceil(data_max / nice_step) * nice_step
ticks = []
val = tick_min
while val <= tick_max + nice_step * 0.01:
ticks.append(round(val, 10))
val += nice_step
return ticks
def nice_date_ticks(date_min: datetime, date_max: datetime, target_count: int = 8) -> list[datetime]:
"""Generate evenly spaced date ticks."""
total_days = (date_max - date_min).days
if total_days <= 0:
return [date_min]
from datetime import timedelta
from dateutil.relativedelta import relativedelta
# Choose interval based on span
if total_days <= 365:
# Monthly ticks
interval = relativedelta(months=1)
elif total_days <= 365 * 3:
# Quarterly
interval = relativedelta(months=3)
elif total_days <= 365 * 8:
# Semi-annual
interval = relativedelta(months=6)
elif total_days <= 365 * 20:
# Annual
interval = relativedelta(years=1)
else:
# Every 2 years
interval = relativedelta(years=2)
# Start from the first day of the month at or after date_min
current = datetime(date_min.year, date_min.month, 1)
if current < date_min:
current += relativedelta(months=1)
ticks = []
while current <= date_max:
ticks.append(current)
current += interval
# Thin out if too many ticks
while len(ticks) > target_count * 1.5:
ticks = ticks[::2]
return ticks