From 5ae79499cbfc571a7389e0a20c732434ed9aa0a0 Mon Sep 17 00:00:00 2001 From: "Adrian A. Baumann" Date: Mon, 13 Jul 2026 00:40:17 +0200 Subject: [PATCH] Add logarithmic scale, custom labels, and fixed-point number formatting - New Linear/Log mapping toggle; log mode uses geometric major-tick values with log-positioned minor ticks (validates min/max > 0) - Optional comma-separated custom labels that override the calculated numbers and set the label count - Format numbers in fixed-point at 4 significant figures (no more scientific notation like "1e+04"), snapping log-scale FP drift to ints - Bump version to 0.75 --- web/app.py | 82 ++++++++++++++++++++++++++++++++-------- web/templates/index.html | 46 ++++++++++++++++++---- 2 files changed, 105 insertions(+), 23 deletions(-) diff --git a/web/app.py b/web/app.py index b25585b..5f87b58 100644 --- a/web/app.py +++ b/web/app.py @@ -1,4 +1,5 @@ import base64 +import math import pathlib from flask import Flask, render_template, request, Response from html import escape @@ -7,7 +8,7 @@ from fontTools.pens.svgPathPen import SVGPathPen app = Flask(__name__) -VERSION = "0.72" +VERSION = "0.75" _LOGO_PATH = pathlib.Path(__file__).parent / 'static' / 'logo.png' _LOGO_B64 = base64.b64encode(_LOGO_PATH.read_bytes()).decode() @@ -49,9 +50,16 @@ def _unit_font_size(unit: str) -> float: def _fmt(v: float) -> str: - if v == int(v): - return str(int(v)) - return f"{v:.4g}" + # Snap near-integers — log-scale geometric values accumulate FP error + # (e.g. 9999.9999), which would otherwise miss the integer case. + r = round(v) + if abs(v - r) < 1e-9 * max(1.0, abs(v)): + return str(int(r)) + # 4 significant figures in fixed-point notation (never scientific). + exp = math.floor(math.log10(abs(v))) + decimals = max(0, 4 - 1 - exp) + s = f"{v:.{decimals}f}" + return s.rstrip('0').rstrip('.') if '.' in s else s def _text_to_paths(text, x, y, font_size, anchor='start', fill='#1a1a1a', wrap_transform=None): @@ -84,12 +92,48 @@ def _text_to_paths(text, x, y, font_size, anchor='start', fill='#1a1a1a', wrap_t return inner -def generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, label_count, use_paths=True): - big_interval = SCALE_LEN / big_ticks - big_xs = [ZERO_X + i * big_interval for i in range(big_ticks + 1)] +def generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, label_count, + use_paths=True, log_scale=False, custom_labels=None): + if log_scale: + if min_val <= 0 or max_val <= 0: + raise ValueError("Logarithmic scale requires min and max values greater than 0.") + lmin, lmax = math.log(min_val), math.log(max_val) + lspan = lmax - lmin + if lspan == 0: + raise ValueError("Logarithmic scale requires min and max values to differ.") - label_count = max(2, min(label_count, big_ticks + 1)) - label_indices = [round(k * big_ticks / (label_count - 1)) for k in range(label_count)] + def value_to_x(v): + return ZERO_X + SCALE_LEN * (math.log(v) - lmin) / lspan + + def major_value(i): + return min_val * (max_val / min_val) ** (i / big_ticks) + else: + span = max_val - min_val + + def value_to_x(v): + if span == 0: + return ZERO_X + return ZERO_X + SCALE_LEN * (v - min_val) / span + + def major_value(i): + return min_val + (max_val - min_val) * i / big_ticks + + big_interval = SCALE_LEN / big_ticks + # Major ticks are always physically evenly spaced; only their values differ. + big_xs = [ZERO_X + i * big_interval for i in range(big_ticks + 1)] + big_vals = [major_value(i) for i in range(big_ticks + 1)] + + # Custom labels, when supplied, override the calculated numbers and set the count. + if custom_labels: + label_count = max(1, min(len(custom_labels), big_ticks + 1)) + custom_labels = custom_labels[:label_count] + else: + label_count = max(2, min(label_count, big_ticks + 1)) + + if label_count == 1: + label_indices = [0] + else: + label_indices = [round(k * big_ticks / (label_count - 1)) for k in range(label_count)] parts = [ '', @@ -135,22 +179,23 @@ def generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, la f' style="stroke:#1a1a1a;stroke-width:0.555;stroke-linecap:butt" />' ) - # Minor ticks + # Minor ticks — evenly spaced in value, then positioned by the scale mapping. + # On a linear scale this is uniform; on a log scale they crowd toward the high end. if small_ticks > 0: sub = small_ticks + 1 for i in range(big_ticks): for j in range(1, small_ticks + 1): - x = big_xs[i] + j * big_interval / sub + v = big_vals[i] + j * (big_vals[i + 1] - big_vals[i]) / sub + x = value_to_x(v) parts.append( f' ' ) # Numeric labels - for idx in label_indices: + for k, idx in enumerate(label_indices): x = big_xs[idx] - val = min_val + (max_val - min_val) * idx / big_ticks - label = _fmt(val) + label = custom_labels[k] if custom_labels else _fmt(big_vals[idx]) if use_paths: parts.append(' ' + _text_to_paths(label, x, 101.49598, 8.89194, anchor='middle')) else: @@ -178,8 +223,15 @@ def generate(): small_ticks = max(0, min(20, int(request.form.get('small_ticks', 4)))) label_count = max(2, int(request.form.get('label_count', 6))) use_paths = request.form.get('output_mode', 'paths') == 'paths' + log_scale = request.form.get('scale_type', 'linear') == 'log' - svg = generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, label_count, use_paths) + custom_labels = [s.strip() for s in request.form.get('labels', '').split(',') if s.strip()] or None + + try: + svg = generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, + label_count, use_paths, log_scale, custom_labels) + except ValueError as err: + return Response(str(err), status=400, mimetype='text/plain') return Response(svg, mimetype='image/svg+xml') diff --git a/web/templates/index.html b/web/templates/index.html index 4a579e6..4a3c317 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -311,7 +311,16 @@
Scale range
-
+
+
+ +
+ + +
+
@@ -321,6 +330,7 @@
+
@@ -340,6 +350,13 @@ (Labels minus one) should divide evenly into Major intervals for best appearance
+
+
+ + + Comma-separated. When set, overrides the calculated numbers and its count sets the number of labels. +
+
@@ -400,13 +417,26 @@ text: 'Live text with embedded font — editable in SVG editors', }; - document.querySelectorAll('.mode-btn').forEach(btn => { - btn.addEventListener('click', () => { - document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active')); - btn.classList.add('active'); - document.getElementById('output-mode').value = btn.dataset.mode; - document.getElementById('mode-desc').textContent = modeDesc[btn.dataset.mode]; - doGenerate(); + document.querySelectorAll('.mode-toggle').forEach(group => { + group.querySelectorAll('.mode-btn').forEach(btn => { + btn.addEventListener('click', () => { + group.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + if (btn.dataset.mode !== undefined) { + document.getElementById('output-mode').value = btn.dataset.mode; + document.getElementById('mode-desc').textContent = modeDesc[btn.dataset.mode]; + } + if (btn.dataset.scale !== undefined) { + document.getElementById('scale-type').value = btn.dataset.scale; + if (btn.dataset.scale === 'log') { + const minInput = document.querySelector('input[name="min"]'); + const maxInput = document.querySelector('input[name="max"]'); + if (!(parseFloat(minInput.value) > 0)) minInput.value = '1'; + if (!(parseFloat(maxInput.value) > 0)) maxInput.value = '100'; + } + } + doGenerate(); + }); }); });