Compare commits

...

14 Commits

Author SHA1 Message Date
adebaumann 11ab6476c9 New logo 2026-07-17 22:40:23 +02:00
adebaumann 1740e0db02 Half-height unit uses lower half of panel box; bump to 0.87
Changed from top half (y0=92.8, y1=103.8) to bottom half (y0=103.8,
y1=114.8). The label baseline sits at y≈101.5mm, so the lower half
starts 2.3mm below it — clean visual separation without the unit
appearing high up on the panel.
2026-07-15 00:17:14 +02:00
adebaumann 6bf7c70231 Font-aware unit sizing; half-height unit toggle; bump to 0.86
Paths-mode unit rendering now always goes through _markup_unit_svg,
which fits the font's actual ink bounds (width AND height) to the
unit box — correct for all fonts, not just ГОСТ тип А.

Added half-height unit toggle: restricts the unit label to the upper
half of the panel box (y1 = midpoint at 103.8 mm), keeping it clear
of the numeric labels when they visually crowd the unit area.
2026-07-15 00:10:02 +02:00
adebaumann 62d9f02a82 Add font selector; load all fonts from fonts/ directory; bump to 0.85
All .ttf files in fonts/ are loaded at startup. The web form now has a
font selector dropdown. Glyph-rendering helpers that previously used
module-level font globals now accept a _FontData parameter threaded
through from generate_svg().

Dockerfile updated to copy all of fonts/ into the image rather than a
single hardcoded .ttf path.
2026-07-14 23:51:55 +02:00
adebaumann 8d45a6d10f Fix reciprocal minor tick spacing; bump to 0.84
Minor ticks on 1/x scales were using linear value interpolation between
harmonically-spaced major ticks. Because 1/v is highly convex, equal value
steps near a large major-value (left side of the first interval) map to tiny
physical steps, piling all ticks against the left wall. At a 1:1000 ratio the
first four minor ticks land at 0%/0%/1%/2% of the interval.

Fix: interpolate in position space (uniform physical spacing) for all
reciprocal intervals, matching how major ticks are laid out (also equal
physical steps) and matching the already-correct ∞-first-interval handling.

Also documents 1/x scale and ∞ max in README.
2026-07-14 23:33:55 +02:00
adebaumann f19c462c1b Add infinity max option for reciprocal scale 2026-07-14 23:28:40 +02:00
adebaumann cf844d007b Add reciprocal (1/x) scale mapping 2026-07-14 23:25:01 +02:00
adebaumann 67b546cc97 Bump version to 0.81 2026-07-14 23:18:37 +02:00
adebaumann a870860f42 Document sqrt scale mapping and update mapping table 2026-07-14 19:31:24 +02:00
adebaumann f9fca519b0 Bump version to 0.80 2026-07-14 19:29:47 +02:00
adebaumann 846fe2d235 Fix sqrt minor ticks: crowd toward low end via squared position 2026-07-14 19:27:40 +02:00
adebaumann 73917aca1e Add square-root scale mapping 2026-07-14 19:17:35 +02:00
adebaumann f676651edb Add unit-label markup: super/subscripts and stacked fractions
Lighter shorthand (^x, _x, {a/b}) rendered as glyph paths in the ГОСТ
font — no LaTeX engine, so typography and self-contained output are
preserved. Constructs nest (e.g. {m/s^2}, x^{1/2}). A marked-up unit is
sized to its true ink bounding box and centered in the unit box with
white space, fitting in both axes so tall fractions don't overflow.

Also fit plain units by measured glyph width rather than a per-character
estimate. Bump to 0.77.
2026-07-14 16:40:50 +02:00
adebaumann edeb696238 Document log mapping, custom labels, and label size in README 2026-07-13 01:18:15 +02:00
12 changed files with 468 additions and 80 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ WORKDIR /app
COPY web/requirements.txt . COPY web/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn RUN pip install --no-cache-dir -r requirements.txt gunicorn
COPY fonts/ГОСТ_тип_А.ttf /usr/local/share/fonts/г/ГОСТ_тип_А.ttf COPY fonts/ /app/fonts/
COPY web/ . COPY web/ .
+32 -5
View File
@@ -40,29 +40,56 @@ source .venv/bin/activate
cd web && python app.py # http://localhost:5000 cd web && python app.py # http://localhost:5000
``` ```
The font must be installed at `/usr/local/share/fonts/г/ГОСТ_тип_А.ttf`. Fonts are loaded at startup from the `fonts/` directory in the repo root.
### Form parameters ### Form parameters
| Field | Description | | Field | Description |
|---|---| |---|---|
| Unit | Symbol shown in the left panel (e.g. `U`, `mA`, `%`) | | Unit | Symbol shown in the left panel (e.g. `U`, `mA`, `%`). Supports inline markup — see below |
| Range label | Text inside the coloured bar on the right (e.g. `5mA`) | | Range label | Text inside the coloured bar on the right (e.g. `5mA`) |
| Mapping | **Linear**, **√** (square root), or **Log**. Major ticks are always evenly spaced physically; the mapping controls their values and how minor ticks are placed within each interval. See below. |
| Min / Max | Numeric range of the scale | | Min / Max | Numeric range of the scale |
| Major intervals | Number of major tick intervals | | Major intervals | Number of major tick intervals |
| Minor per interval | Minor ticks between each pair of major ticks | | Minor per interval | Minor ticks between each pair of major ticks |
| Labels | Number of numeric labels; Labels 1 should divide evenly into Major intervals | | Labels | Number of numeric labels; Labels 1 should divide evenly into Major intervals |
| Custom labels | Optional comma-separated labels that override the calculated numbers; their count sets the number of labels. Leave blank to auto-calculate |
| Label size (mm) | Font size of the numeric labels, in millimetres (default `9`) |
Calculated numbers are rendered in fixed-point notation at 4 significant figures — never scientific (e.g. `10000`, not `1e+04`).
### Scale mappings
| Mapping | Major tick values | Minor tick placement | Constraints |
|---|---|---|---|
| Linear | Evenly spaced | Uniform | None |
| √ | Quadratic (0, 1, 4, 9 … × max) — compresses low values | Crowded toward the low end of each interval | Min ≥ 0 |
| Log | Geometric (min × r⁰, r¹, r² …) — each interval spans the same ratio | Crowded toward the high end of each interval | Min and Max > 0 |
| 1/x | Harmonic (values decrease left → right, e.g. ∞, 10, 5, 2, 1) — compresses high values | Uniform (in position space, i.e. proportional to 1/v) | Min and Max > 0; Max may be ∞ |
The √ mapping is typical for AC RMS current and power meters, where needle deflection is proportional to the square of the measured quantity. The 1/x mapping is used for ohms/resistance scales, where values decrease from left (high resistance / ∞) to right (low resistance).
### Unit markup
The Unit field accepts a lightweight shorthand (no LaTeX engine — everything is drawn as glyph paths in the ГОСТ тип А font and auto-sized to fit the panel):
| Markup | Result |
|---|---|
| `cm^2` | superscript (`^{...}` for multiple characters) |
| `H_2O` | subscript (`_{...}` for multiple characters) |
| `{1/2}` | stacked fraction with a horizontal rule; a bare `/` stays inline |
Constructs nest: `{m/s^2}` is a fraction with a superscript in the denominator, `x^{1/2}` a superscripted fraction. A unit containing markup is always emitted as paths, even in text-output mode, since it is a composite of glyphs and a rule.
## Container ## Container
```bash ```bash
# Copy the font first (not redistributed via the registry)
cp /usr/local/share/fonts/г/ГОСТ_тип_А.ttf fonts/
docker build -t m1730-scale:latest . docker build -t m1730-scale:latest .
docker run -p 5000:5000 m1730-scale:latest docker run -p 5000:5000 m1730-scale:latest
``` ```
Fonts are loaded from `fonts/` in the repo root; they are bundled into the image at build time.
The production image is at `git.baumann.gr/adebaumann/m1730-generator`. The production image is at `git.baumann.gr/adebaumann/m1730-generator`.
## Kubernetes ## Kubernetes
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -1
View File
@@ -15,7 +15,7 @@ spec:
spec: spec:
containers: containers:
- name: m1730-generator - name: m1730-generator
image: git.baumann.gr/adebaumann/m1730-generator:0.76 image: git.baumann.gr/adebaumann/m1730-generator:0.87
imagePullPolicy: Always imagePullPolicy: Always
ports: ports:
- containerPort: 5000 - containerPort: 5000
+356 -69
View File
@@ -1,6 +1,7 @@
import base64 import base64
import math import math
import pathlib import pathlib
from collections import namedtuple
from flask import Flask, render_template, request, Response from flask import Flask, render_template, request, Response
from html import escape from html import escape
from fontTools.ttLib import TTFont from fontTools.ttLib import TTFont
@@ -8,27 +9,46 @@ from fontTools.pens.svgPathPen import SVGPathPen
app = Flask(__name__) app = Flask(__name__)
VERSION = "0.76" VERSION = "0.87"
_LOGO_PATH = pathlib.Path(__file__).parent / 'static' / 'logo.png' _LOGO_PATH = pathlib.Path(__file__).parent / 'static' / 'logo.png'
_LOGO_B64 = base64.b64encode(_LOGO_PATH.read_bytes()).decode() _LOGO_B64 = base64.b64encode(_LOGO_PATH.read_bytes()).decode()
_FONT_PATH = pathlib.Path('/usr/local/share/fonts/г/ГОСТ_тип_А.ttf') _FontData = namedtuple('_FontData', ['key', 'label', 'b64', 'glyph_set', 'cmap', 'hmtx', 'upm', 'glyf'])
_FONT_B64 = base64.b64encode(_FONT_PATH.read_bytes()).decode()
_FONT_FACE = (
'<defs><style>'
'@font-face {'
'font-family:"ГОСТ тип А";'
f'src:url("data:font/truetype;base64,{_FONT_B64}") format("truetype");'
'}'
'</style></defs>'
)
_FONT_OBJ = TTFont(str(_FONT_PATH)) _FONT_LABELS = {
_GLYPH_SET = _FONT_OBJ.getGlyphSet() 'GOST_2.30481_type_A': 'GOST Type A',
_CMAP = _FONT_OBJ.getBestCmap() 'GOST_2.30481_type_A_Bold': 'GOST Type A Bold',
_HMTX = _FONT_OBJ['hmtx'].metrics 'Gost2.30481TypeB': 'GOST Type B',
_UPM = _FONT_OBJ['head'].unitsPerEm 'ISOCPEUR': 'ISOCPEUR',
'SIEMENS_GOST_Type_A': 'Siemens GOST Type A',
'TGL_31034-1': 'TGL 31 034',
}
def _load_fonts():
font_dir = pathlib.Path(__file__).parent / 'fonts'
if not font_dir.exists():
font_dir = pathlib.Path(__file__).parent.parent / 'fonts'
fonts = {}
for path in sorted(font_dir.glob('*.ttf')):
key = path.stem
obj = TTFont(str(path))
fonts[key] = _FontData(
key=key,
label=_FONT_LABELS.get(key, key.replace('_', ' ')),
b64=base64.b64encode(path.read_bytes()).decode(),
glyph_set=obj.getGlyphSet(),
cmap=obj.getBestCmap(),
hmtx=obj['hmtx'].metrics,
upm=obj['head'].unitsPerEm,
glyf=obj['glyf'],
)
return fonts
_FONTS = _load_fonts()
_DEFAULT_FONT = 'GOST_2.30481_type_A'
# SVG absolute coordinate constants (1 user unit = 1 mm) # SVG absolute coordinate constants (1 user unit = 1 mm)
ZERO_X = 60.844130 ZERO_X = 60.844130
@@ -41,50 +61,243 @@ SMALL_H = 2.6683
_UNIT_AREA_WIDTH = 18.445 _UNIT_AREA_WIDTH = 18.445
_UNIT_SCALE_X = 0.96486402 _UNIT_SCALE_X = 0.96486402
_UNIT_CHAR_EM = 0.65 _UNIT_MAX_FS = 18.7981
# --- Inline markup for the unit label -------------------------------------
_SUP_SCALE = 0.62
_SUP_RISE = 0.36
_SUB_DROP = 0.14
_FRAC_SCALE = 0.62
_FRAC_AXIS = 0.32
_FRAC_GAP = 0.14
_FRAC_SIDE = 0.12
_UNIT_BOX_X0, _UNIT_BOX_X1 = 39.6, 57.4
_UNIT_BOX_Y0, _UNIT_BOX_Y1 = 92.8, 114.8
_UNIT_BOX_HALF_Y0 = (_UNIT_BOX_Y0 + _UNIT_BOX_Y1) / 2 # 103.8 — bottom half starts here
def _unit_font_size(unit: str) -> float: def _glyph_svg(ch: str, x: float, y: float, fs: float, fill: str, fd: _FontData):
n = max(1, len(unit)) gn = fd.cmap.get(ord(ch))
return min(18.7981, _UNIT_AREA_WIDTH / (n * _UNIT_SCALE_X * _UNIT_CHAR_EM)) if gn is None:
return None, 0.0
s = fs / fd.upm
adv = fd.hmtx.get(gn, (0, 0))[0] * s
pen = SVGPathPen(fd.glyph_set)
fd.glyph_set[gn].draw(pen)
d = pen.getCommands()
if not d:
return None, adv
t = f'translate({x:.5f},{y:.5f}) scale({s:.8f},{-s:.8f})'
return f'<path style="fill:{fill}" transform="{t}" d="{d}"/>', adv
def _fmt(v: float) -> str: def _glyph_ybounds(ch: str, fd: _FontData):
# Snap near-integers — log-scale geometric values accumulate FP error gn = fd.cmap.get(ord(ch))
# (e.g. 9999.9999), which would otherwise miss the integer case. if gn is None:
r = round(v) return 0.0, 0.0
if abs(v - r) < 1e-9 * max(1.0, abs(v)): g = fd.glyf[gn]
return str(int(r)) if g.numberOfContours == 0:
# 4 significant figures in fixed-point notation (never scientific). return 0.0, 0.0
exp = math.floor(math.log10(abs(v))) g.recalcBounds(fd.glyf)
decimals = max(0, 4 - 1 - exp) return g.yMin / fd.upm, g.yMax / fd.upm
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): def _extract_braced(s: str, i: int):
s = font_size / _UPM depth, j = 1, i + 1
while j < len(s) and depth:
if s[j] == '{':
depth += 1
elif s[j] == '}':
depth -= 1
if depth == 0:
break
j += 1
return s[i + 1:j], j + 1
def _parse_braced(inner: str):
depth = 0
for k, ch in enumerate(inner):
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
elif ch == '/' and depth == 0:
return ('frac', _parse_markup(inner[:k]), _parse_markup(inner[k + 1:]))
return ('group', _parse_markup(inner))
def _parse_atom(s: str, i: int):
if i >= len(s):
return [], i
if s[i] == '{':
inner, j = _extract_braced(s, i)
return [_parse_braced(inner)], j
return [('lit', s[i])], i + 1
def _parse_markup(s: str):
nodes, i = [], 0
while i < len(s):
c = s[i]
if c in '^_':
atom, i = _parse_atom(s, i + 1)
nodes.append(('sup' if c == '^' else 'sub', atom))
elif c == '{':
inner, j = _extract_braced(s, i)
nodes.append(_parse_braced(inner))
i = j
else:
nodes.append(('lit', c))
i += 1
return nodes
def _measure(nodes, fs: float, fd: _FontData) -> float:
w = 0.0
for n in nodes:
if n[0] == 'lit':
gn = fd.cmap.get(ord(n[1]))
if gn:
w += fd.hmtx.get(gn, (0, 0))[0] / fd.upm * fs
elif n[0] == 'group':
w += _measure(n[1], fs, fd)
elif n[0] in ('sup', 'sub'):
w += _measure(n[1], fs * _SUP_SCALE, fd)
elif n[0] == 'frac':
fs2 = fs * _FRAC_SCALE
w += max(_measure(n[1], fs2, fd), _measure(n[2], fs2, fd)) + 2 * _FRAC_SIDE * fs2
return w
def _frac_layout(n, fs: float, fd: _FontData):
fs2 = fs * _FRAC_SCALE
axis = -_FRAC_AXIS * fs
_, num_bot = _vbounds(n[1], fs2, fd)
num_base = axis - _FRAC_GAP * fs2 - num_bot
den_top, _ = _vbounds(n[2], fs2, fd)
den_base = axis + _FRAC_GAP * fs2 - den_top
return fs2, axis, num_base, den_base
def _vbounds(nodes, fs: float, fd: _FontData):
lo, hi = math.inf, -math.inf
for n in nodes:
if n[0] == 'lit':
ymin, ymax = _glyph_ybounds(n[1], fd)
a, b = -ymax * fs, -ymin * fs
elif n[0] == 'group':
a, b = _vbounds(n[1], fs, fd)
elif n[0] == 'sup':
a, b = _vbounds(n[1], fs * _SUP_SCALE, fd)
a, b = a - _SUP_RISE * fs, b - _SUP_RISE * fs
elif n[0] == 'sub':
a, b = _vbounds(n[1], fs * _SUP_SCALE, fd)
a, b = a + _SUB_DROP * fs, b + _SUB_DROP * fs
elif n[0] == 'frac':
fs2, axis, num_base, den_base = _frac_layout(n, fs, fd)
nt, nb = _vbounds(n[1], fs2, fd)
dt, db = _vbounds(n[2], fs2, fd)
a = min(nt + num_base, axis)
b = max(db + den_base, axis)
else:
continue
lo, hi = min(lo, a), max(hi, b)
return (0.0, 0.0) if lo > hi else (lo, hi)
def _draw_nodes(nodes, x: float, y: float, fs: float, fill: str, fd: _FontData):
parts, cx = [], x
for n in nodes:
if n[0] == 'lit':
p, adv = _glyph_svg(n[1], cx, y, fs, fill, fd)
if p:
parts.append(p)
cx += adv
elif n[0] == 'group':
p, adv = _draw_nodes(n[1], cx, y, fs, fill, fd)
parts += p
cx += adv
elif n[0] in ('sup', 'sub'):
fs2 = fs * _SUP_SCALE
y2 = y - _SUP_RISE * fs if n[0] == 'sup' else y + _SUB_DROP * fs
p, adv = _draw_nodes(n[1], cx, y2, fs2, fill, fd)
parts += p
cx += adv
elif n[0] == 'frac':
fs2, axis, num_base, den_base = _frac_layout(n, fs, fd)
wn, wd = _measure(n[1], fs2, fd), _measure(n[2], fs2, fd)
bar_w = max(wn, wd) + 2 * _FRAC_SIDE * fs2
pn, _ = _draw_nodes(n[1], cx + (bar_w - wn) / 2, y + num_base, fs2, fill, fd)
pd, _ = _draw_nodes(n[2], cx + (bar_w - wd) / 2, y + den_base, fs2, fill, fd)
parts += pn + pd
ay = y + axis
parts.append(
f'<line x1="{cx:.5f}" y1="{ay:.5f}" x2="{cx + bar_w:.5f}" y2="{ay:.5f}"'
f' style="stroke:{fill};stroke-width:{fs * 0.055:.5f};stroke-linecap:butt" />'
)
cx += bar_w
return parts, cx - x
def _has_markup(s: str) -> bool:
return any(c in s for c in '^_') or ('{' in s and '}' in s)
def _markup_unit_svg(unit: str, fd: _FontData, fill='#1a1a1a', box_y0=None, box_y1=None) -> str:
nodes = _parse_markup(unit)
x0, x1 = _UNIT_BOX_X0, _UNIT_BOX_X1
y0 = box_y0 if box_y0 is not None else _UNIT_BOX_Y0
y1 = box_y1 if box_y1 is not None else _UNIT_BOX_Y1
xc, yc = (x0 + x1) / 2, (y0 + y1) / 2
w1 = _measure(nodes, 1.0, fd)
top1, bot1 = _vbounds(nodes, 1.0, fd)
fs = _UNIT_MAX_FS
if w1 > 0:
fs = min(fs, (x1 - x0) / (w1 * _UNIT_SCALE_X))
if bot1 - top1 > 0:
fs = min(fs, (y1 - y0) / (bot1 - top1))
w = _measure(nodes, fs, fd)
top, bot = _vbounds(nodes, fs, fd)
y_base = yc - (top + bot) / 2
parts, _ = _draw_nodes(nodes, xc - w / 2, y_base, fs, fill, fd)
cond = f'translate({xc:.5f},0) scale({_UNIT_SCALE_X},1) translate({-xc:.5f},0)'
return f'<g transform="{cond}">\n{chr(10).join(parts)}\n</g>'
def _unit_font_size(unit: str, fd: _FontData) -> float:
em_w = _measure(_parse_markup(unit), 1.0, fd)
if em_w <= 0:
return _UNIT_MAX_FS
return min(_UNIT_MAX_FS, _UNIT_AREA_WIDTH / (em_w * _UNIT_SCALE_X))
def _text_to_paths(text, x, y, font_size, fd: _FontData, anchor='start', fill='#1a1a1a', wrap_transform=None):
s = font_size / fd.upm
if anchor == 'middle': if anchor == 'middle':
total_w = sum( total_w = sum(
_HMTX.get(_CMAP.get(ord(ch)), (0, 0))[0] * s fd.hmtx.get(fd.cmap.get(ord(ch)), (0, 0))[0] * s
for ch in text if _CMAP.get(ord(ch)) for ch in text if fd.cmap.get(ord(ch))
) )
x -= total_w / 2 x -= total_w / 2
parts = [] parts = []
cx = x cx = x
for ch in text: for ch in text:
gn = _CMAP.get(ord(ch)) gn = fd.cmap.get(ord(ch))
if gn is None: if gn is None:
continue continue
pen = SVGPathPen(_GLYPH_SET) pen = SVGPathPen(fd.glyph_set)
_GLYPH_SET[gn].draw(pen) fd.glyph_set[gn].draw(pen)
d = pen.getCommands() d = pen.getCommands()
if d: if d:
t = f'translate({cx:.5f},{y:.5f}) scale({s:.8f},{-s:.8f})' t = f'translate({cx:.5f},{y:.5f}) scale({s:.8f},{-s:.8f})'
parts.append(f'<path style="fill:{fill}" transform="{t}" d="{d}"/>') parts.append(f'<path style="fill:{fill}" transform="{t}" d="{d}"/>')
cx += _HMTX.get(gn, (0, 0))[0] * s cx += fd.hmtx.get(gn, (0, 0))[0] * s
inner = '\n'.join(parts) inner = '\n'.join(parts)
if wrap_transform: if wrap_transform:
@@ -92,9 +305,25 @@ def _text_to_paths(text, x, y, font_size, anchor='start', fill='#1a1a1a', wrap_t
return inner return inner
def _fmt(v: float) -> str:
if math.isinf(v):
return ''
r = round(v)
if abs(v - r) < 1e-9 * max(1.0, abs(v)):
return str(int(r))
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 generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, label_count, 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, label_size=9): use_paths=True, scale_type='linear', custom_labels=None, label_size=9,
if log_scale: font_key=None, half_height_unit=False):
fd = _FONTS.get(font_key) or _FONTS.get(_DEFAULT_FONT) or next(iter(_FONTS.values()))
unit_box_y0 = _UNIT_BOX_HALF_Y0 if half_height_unit else None
if scale_type == 'log':
if min_val <= 0 or max_val <= 0: if min_val <= 0 or max_val <= 0:
raise ValueError("Logarithmic scale requires min and max values greater than 0.") raise ValueError("Logarithmic scale requires min and max values greater than 0.")
lmin, lmax = math.log(min_val), math.log(max_val) lmin, lmax = math.log(min_val), math.log(max_val)
@@ -107,6 +336,46 @@ def generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, la
def major_value(i): def major_value(i):
return min_val * (max_val / min_val) ** (i / big_ticks) return min_val * (max_val / min_val) ** (i / big_ticks)
elif scale_type == 'sqrt':
if min_val < 0:
raise ValueError("Square-root scale requires min value ≥ 0.")
span = max_val - min_val
if span <= 0:
raise ValueError("Square-root scale requires max value greater than min.")
def value_to_x(v):
return ZERO_X + SCALE_LEN * math.sqrt((v - min_val) / span)
def major_value(i):
return min_val + span * (i / big_ticks) ** 2
elif scale_type == 'reciprocal':
if min_val <= 0:
raise ValueError("Reciprocal scale requires min and max values greater than 0.")
if not math.isinf(max_val) and max_val <= min_val:
raise ValueError("Reciprocal scale requires max value greater than min.")
if math.isinf(max_val):
def value_to_x(v):
if math.isinf(v):
return ZERO_X
return ZERO_X + SCALE_LEN * min_val / v
def major_value(i):
if i == 0:
return math.inf
return min_val * big_ticks / i
else:
if max_val <= 0:
raise ValueError("Reciprocal scale requires min and max values greater than 0.")
k = 1.0 / min_val - 1.0 / max_val
def value_to_x(v):
return ZERO_X + SCALE_LEN * (1.0 / v - 1.0 / max_val) / k
def major_value(i):
return 1.0 / (1.0 / max_val + i / big_ticks * k)
else: else:
span = max_val - min_val span = max_val - min_val
@@ -116,14 +385,12 @@ def generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, la
return ZERO_X + SCALE_LEN * (v - min_val) / span return ZERO_X + SCALE_LEN * (v - min_val) / span
def major_value(i): def major_value(i):
return min_val + (max_val - min_val) * i / big_ticks return min_val + span * i / big_ticks
big_interval = SCALE_LEN / 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_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)] 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: if custom_labels:
label_count = max(1, min(len(custom_labels), big_ticks + 1)) label_count = max(1, min(len(custom_labels), big_ticks + 1))
custom_labels = custom_labels[:label_count] custom_labels = custom_labels[:label_count]
@@ -135,6 +402,13 @@ def generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, la
else: else:
label_indices = [round(k * big_ticks / (label_count - 1)) for k in range(label_count)] label_indices = [round(k * big_ticks / (label_count - 1)) for k in range(label_count)]
font_face = (
'<defs><style>'
f'@font-face {{font-family:"{fd.label}";'
f'src:url("data:font/truetype;base64,{fd.b64}") format("truetype");}}'
'</style></defs>'
)
parts = [ parts = [
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>', '<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
'<svg width="157.18373mm" height="26.683672mm"' '<svg width="157.18373mm" height="26.683672mm"'
@@ -145,7 +419,7 @@ def generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, la
if use_paths: if use_paths:
parts.append(' <g transform="translate(-37.352226,-90.454928)">') parts.append(' <g transform="translate(-37.352226,-90.454928)">')
else: else:
parts += [_FONT_FACE, ' <g transform="translate(-37.352226,-90.454928)">'] parts += [font_face, ' <g transform="translate(-37.352226,-90.454928)">']
parts.append( parts.append(
' <path style="fill:#ffffff;stroke:#1a1a1a;stroke-width:0.184;' ' <path style="fill:#ffffff;stroke:#1a1a1a;stroke-width:0.184;'
@@ -154,53 +428,59 @@ def generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, la
' M 58.443978,107.54692 H 183.64409 v 6.19963 H 58.443978 Z" />' ' M 58.443978,107.54692 H 183.64409 v 6.19963 H 58.443978 Z" />'
) )
if use_paths: # Paths mode and any unit needing special handling (markup, half-height):
parts += [ # use _markup_unit_svg which fits actual ink bounds to the box, giving
' ' + _text_to_paths( # correct sizing for all fonts. Text mode for plain normal-height units
unit, 41.455711, 109.03796, _unit_font_size(unit), # keeps live <text> nodes; half-height always uses paths for correct centering.
wrap_transform=f'scale({_UNIT_SCALE_X},1.0364155)' if use_paths or _has_markup(unit) or half_height_unit:
), unit_svg = ' ' + _markup_unit_svg(unit, fd, box_y0=unit_box_y0)
' ' + _text_to_paths(range_label, 184.79707, 113.6755, 3.60539),
]
else: else:
fs = _unit_font_size(unit) fs = _unit_font_size(unit, fd)
parts += [ unit_svg = (
f' <text style="font-size:{fs:.4f}px;font-family:\'ГОСТ тип А\',monospace;fill:#1a1a1a"' f' <text style="font-size:{fs:.4f}px;font-family:\'{fd.label}\',monospace;fill:#1a1a1a"'
f' x="41.455711" y="109.03796"' f' x="41.455711" y="109.03796"'
f' transform="scale({_UNIT_SCALE_X},1.0364155)">{escape(unit)}</text>', f' transform="scale({_UNIT_SCALE_X},1.0364155)">{escape(unit)}</text>'
f' <text style="font-size:3.60539px;font-family:\'ГОСТ тип А\',monospace;fill:#1a1a1a"' )
if use_paths:
parts += [unit_svg, ' ' + _text_to_paths(range_label, 184.79707, 113.6755, 3.60539, fd)]
else:
parts += [
unit_svg,
f' <text style="font-size:3.60539px;font-family:\'{fd.label}\',monospace;fill:#1a1a1a"'
f' x="184.79707" y="113.6755">{escape(range_label)}</text>', f' x="184.79707" y="113.6755">{escape(range_label)}</text>',
] ]
# Major ticks
for x in big_xs: for x in big_xs:
parts.append( parts.append(
f' <line x1="{x:.5f}" y1="{BAR_Y}" x2="{x:.5f}" y2="{BAR_Y - BIG_H:.5f}"' f' <line x1="{x:.5f}" y1="{BAR_Y}" x2="{x:.5f}" y2="{BAR_Y - BIG_H:.5f}"'
f' style="stroke:#1a1a1a;stroke-width:0.555;stroke-linecap:butt" />' f' style="stroke:#1a1a1a;stroke-width:0.555;stroke-linecap:butt" />'
) )
# 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: if small_ticks > 0:
sub = small_ticks + 1 sub = small_ticks + 1
for i in range(big_ticks): for i in range(big_ticks):
for j in range(1, small_ticks + 1): for j in range(1, small_ticks + 1):
v = big_vals[i] + j * (big_vals[i + 1] - big_vals[i]) / sub if scale_type == 'sqrt':
x = value_to_x(v) x = big_xs[i] + (j / sub) ** 2 * (big_xs[i + 1] - big_xs[i])
elif scale_type == 'reciprocal' or math.isinf(big_vals[i]):
x = big_xs[i] + (j / sub) * (big_xs[i + 1] - big_xs[i])
else:
v = big_vals[i] + j * (big_vals[i + 1] - big_vals[i]) / sub
x = value_to_x(v)
parts.append( parts.append(
f' <line x1="{x:.5f}" y1="{BAR_Y}" x2="{x:.5f}" y2="{BAR_Y - SMALL_H:.5f}"' f' <line x1="{x:.5f}" y1="{BAR_Y}" x2="{x:.5f}" y2="{BAR_Y - SMALL_H:.5f}"'
f' style="stroke:#1a1a1a;stroke-width:0.302;stroke-linecap:butt" />' f' style="stroke:#1a1a1a;stroke-width:0.302;stroke-linecap:butt" />'
) )
# Numeric labels
for k, idx in enumerate(label_indices): for k, idx in enumerate(label_indices):
x = big_xs[idx] x = big_xs[idx]
label = custom_labels[k] if custom_labels else _fmt(big_vals[idx]) label = custom_labels[k] if custom_labels else _fmt(big_vals[idx])
if use_paths: if use_paths:
parts.append(' ' + _text_to_paths(label, x, 101.49598, label_size, anchor='middle')) parts.append(' ' + _text_to_paths(label, x, 101.49598, label_size, fd, anchor='middle'))
else: else:
parts.append( parts.append(
f' <text style="font-size:{label_size:.5f}px;font-family:\'ГОСТ тип А\',monospace;fill:#1a1a1a"' f' <text style="font-size:{label_size:.5f}px;font-family:\'{fd.label}\',monospace;fill:#1a1a1a"'
f' text-anchor="middle" x="{x:.5f}" y="101.49598">{escape(label)}</text>' f' text-anchor="middle" x="{x:.5f}" y="101.49598">{escape(label)}</text>'
) )
@@ -210,7 +490,9 @@ def generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, la
@app.route('/') @app.route('/')
def index(): def index():
return render_template('index.html', logo_b64=_LOGO_B64, version=VERSION) fonts = list(_FONTS.values())
return render_template('index.html', logo_b64=_LOGO_B64, version=VERSION,
fonts=fonts, default_font=_DEFAULT_FONT)
@app.route('/generate', methods=['POST']) @app.route('/generate', methods=['POST'])
@@ -223,14 +505,19 @@ def generate():
small_ticks = max(0, min(20, int(request.form.get('small_ticks', 4)))) small_ticks = max(0, min(20, int(request.form.get('small_ticks', 4))))
label_count = max(2, int(request.form.get('label_count', 6))) label_count = max(2, int(request.form.get('label_count', 6)))
use_paths = request.form.get('output_mode', 'paths') == 'paths' use_paths = request.form.get('output_mode', 'paths') == 'paths'
log_scale = request.form.get('scale_type', 'linear') == 'log' scale_type = request.form.get('scale_type', 'linear')
font_key = request.form.get('font', _DEFAULT_FONT)
half_height_unit = request.form.get('half_height_unit') == '1'
if request.form.get('max_is_inf') == '1':
max_val = math.inf
custom_labels = [s.strip() for s in request.form.get('labels', '').split(',') if s.strip()] or None custom_labels = [s.strip() for s in request.form.get('labels', '').split(',') if s.strip()] or None
label_size = max(0.5, min(25.0, float(request.form.get('label_size', 9)))) label_size = max(0.5, min(25.0, float(request.form.get('label_size', 9))))
try: try:
svg = generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, svg = generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks,
label_count, use_paths, log_scale, custom_labels, label_size) label_count, use_paths, scale_type, custom_labels, label_size,
font_key, half_height_unit)
except ValueError as err: except ValueError as err:
return Response(str(err), status=400, mimetype='text/plain') return Response(str(err), status=400, mimetype='text/plain')
return Response(svg, mimetype='image/svg+xml') return Response(svg, mimetype='image/svg+xml')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 942 KiB

+78 -4
View File
@@ -301,6 +301,15 @@
<div class="field"> <div class="field">
<label>Unit</label> <label>Unit</label>
<input name="unit" type="text" value="%" required> <input name="unit" type="text" value="%" required>
<span style="font-family:var(--f-mono);font-size:0.65rem;color:var(--muted)">Markup: cm^2 · H_2O · {1/2} fraction · nests, e.g. {m/s^2}</span>
<div style="display:flex;align-items:center;gap:0.5rem;margin-top:0.2rem">
<div class="mode-toggle" role="group" aria-label="Unit height">
<button type="button" class="mode-btn active" data-unit-height="normal">Full height</button>
<button type="button" class="mode-btn" data-unit-height="half"
title="Restrict unit label to the upper half of the panel — use when numeric labels visually crowd the unit">Half height</button>
</div>
<input type="hidden" name="half_height_unit" id="half-height-unit" value="0">
</div>
</div> </div>
<div class="field"> <div class="field">
<label>Range label</label> <label>Range label</label>
@@ -317,17 +326,23 @@
<div class="mode-toggle" role="group" aria-label="Scale mapping"> <div class="mode-toggle" role="group" aria-label="Scale mapping">
<button type="button" class="mode-btn active" data-scale="linear" <button type="button" class="mode-btn active" data-scale="linear"
title="Values spaced evenly along the scale">Linear</button> title="Values spaced evenly along the scale">Linear</button>
<button type="button" class="mode-btn" data-scale="sqrt"
title="Square-root scale — ticks crowd at the low end; used for RMS current/power meters. Min must be ≥ 0"></button>
<button type="button" class="mode-btn" data-scale="log" <button type="button" class="mode-btn" data-scale="log"
title="Values spaced logarithmically — requires min and max greater than 0">Log</button> title="Values spaced logarithmically — requires min and max greater than 0">Log</button>
<button type="button" class="mode-btn" data-scale="reciprocal"
title="Reciprocal (1/x) scale — values decrease left to right; used for resistance (ohms) scales. Min and Max must be greater than 0, Max greater than Min">1/x</button>
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<label>Min value</label> <label>Min value</label>
<input name="min" type="number" step="any" value="0" required> <input name="min" type="number" step="any" value="0" required>
</div> </div>
<div class="field"> <div class="field" id="field-max">
<label>Max value</label> <label>Max value</label>
<input name="max" type="number" step="any" value="100" required> <input name="max" id="input-max" type="number" step="any" value="100" required>
<button type="button" id="btn-inf" style="display:none;font-family:var(--f-mono);font-size:0.65rem;color:var(--muted);background:none;border:none;padding:0;cursor:pointer;text-align:left">set max = ∞</button>
<input type="hidden" name="max_is_inf" id="max-is-inf" value="">
</div> </div>
</div> </div>
<input type="hidden" name="scale_type" id="scale-type" value="linear"> <input type="hidden" name="scale_type" id="scale-type" value="linear">
@@ -361,6 +376,16 @@
<input name="label_size" type="number" step="any" min="0.5" max="25" value="9" required> <input name="label_size" type="number" step="any" min="0.5" max="25" value="9" required>
</div> </div>
</div> </div>
<div class="row row-2" style="margin-top:1.25rem">
<div class="field">
<label>Font</label>
<select name="font" id="font-select" style="background:var(--white);border:1px solid var(--rule);border-radius:1px;padding:0.5rem 0.7rem;font-family:var(--f-body);font-size:0.875rem;color:var(--ink);width:100%;outline:none;cursor:pointer">
{% for f in fonts %}
<option value="{{ f.key }}"{% if f.key == default_font %} selected{% endif %}>{{ f.label }}</option>
{% endfor %}
</select>
</div>
</div>
</div> </div>
<div class="actions"> <div class="actions">
@@ -392,6 +417,31 @@
<script> <script>
let lastSvg = ''; let lastSvg = '';
function resetInf() {
const inp = document.getElementById('input-max');
const hidden = document.getElementById('max-is-inf');
const btn = document.getElementById('btn-inf');
inp.style.display = '';
inp.required = true;
hidden.value = '';
btn.textContent = 'set max = ∞';
}
document.getElementById('btn-inf').addEventListener('click', () => {
const inp = document.getElementById('input-max');
const hidden = document.getElementById('max-is-inf');
const btn = document.getElementById('btn-inf');
if (hidden.value === '1') {
resetInf();
} else {
inp.style.display = 'none';
inp.required = false;
hidden.value = '1';
btn.textContent = 'max = ∞ (click to reset)';
}
doGenerate();
});
async function doGenerate() { async function doGenerate() {
const status = document.getElementById('status'); const status = document.getElementById('status');
status.textContent = 'Generating…'; status.textContent = 'Generating…';
@@ -432,11 +482,24 @@
} }
if (btn.dataset.scale !== undefined) { if (btn.dataset.scale !== undefined) {
document.getElementById('scale-type').value = btn.dataset.scale; document.getElementById('scale-type').value = btn.dataset.scale;
const minInput = document.querySelector('input[name="min"]');
const maxInput = document.querySelector('input[name="max"]');
if (btn.dataset.scale === 'log') { 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(minInput.value) > 0)) minInput.value = '1';
if (!(parseFloat(maxInput.value) > 0)) maxInput.value = '100'; if (!(parseFloat(maxInput.value) > 0)) maxInput.value = '100';
} else if (btn.dataset.scale === 'sqrt') {
if (parseFloat(minInput.value) < 0) minInput.value = '0';
} else if (btn.dataset.scale === 'reciprocal') {
if (!(parseFloat(minInput.value) > 0)) minInput.value = '1';
if (!(parseFloat(maxInput.value) > parseFloat(minInput.value))) maxInput.value = String(parseFloat(minInput.value) * 10);
}
// Show/hide ∞ toggle
const infBtn = document.getElementById('btn-inf');
if (btn.dataset.scale === 'reciprocal') {
infBtn.style.display = '';
} else {
infBtn.style.display = 'none';
resetInf();
} }
} }
doGenerate(); doGenerate();
@@ -454,6 +517,17 @@
URL.revokeObjectURL(a.href); URL.revokeObjectURL(a.href);
}); });
document.getElementById('font-select').addEventListener('change', doGenerate);
document.querySelectorAll('[data-unit-height]').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('[data-unit-height]').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.getElementById('half-height-unit').value = btn.dataset.unitHeight === 'half' ? '1' : '0';
doGenerate();
});
});
doGenerate(); doGenerate();
</script> </script>
</body> </body>