Compare commits

..

16 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
adebaumann 54a7f072c4 Add configurable label font size; bump to 0.76
- New "Label size (mm)" field controlling the numeric-label font size,
  defaulting to a clean 9 mm (was the inherited Inkscape value 8.89194)
- Pin k8s deployment to image tag 0.76
2026-07-13 00:59:14 +02:00
adebaumann 5ae79499cb 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
2026-07-13 00:40:36 +02:00
12 changed files with 552 additions and 77 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ WORKDIR /app
COPY web/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt gunicorn
COPY fonts/ГОСТ_тип_А.ttf /usr/local/share/fonts/г/ГОСТ_тип_А.ttf
COPY fonts/ /app/fonts/
COPY web/ .
+32 -5
View File
@@ -40,29 +40,56 @@ source .venv/bin/activate
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
| 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`) |
| 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 |
| Major intervals | Number of major tick intervals |
| Minor per interval | Minor ticks between each pair of major ticks |
| 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
```bash
# Copy the font first (not redistributed via the registry)
cp /usr/local/share/fonts/г/ГОСТ_тип_А.ttf fonts/
docker build -t 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`.
## 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:
containers:
- name: m1730-generator
image: git.baumann.gr/adebaumann/m1730-generator:0.72
image: git.baumann.gr/adebaumann/m1730-generator:0.87
imagePullPolicy: Always
ports:
- containerPort: 5000
+400 -60
View File
@@ -1,5 +1,7 @@
import base64
import math
import pathlib
from collections import namedtuple
from flask import Flask, render_template, request, Response
from html import escape
from fontTools.ttLib import TTFont
@@ -7,27 +9,46 @@ from fontTools.pens.svgPathPen import SVGPathPen
app = Flask(__name__)
VERSION = "0.72"
VERSION = "0.87"
_LOGO_PATH = pathlib.Path(__file__).parent / 'static' / 'logo.png'
_LOGO_B64 = base64.b64encode(_LOGO_PATH.read_bytes()).decode()
_FONT_PATH = pathlib.Path('/usr/local/share/fonts/г/ГОСТ_тип_А.ttf')
_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>'
)
_FontData = namedtuple('_FontData', ['key', 'label', 'b64', 'glyph_set', 'cmap', 'hmtx', 'upm', 'glyf'])
_FONT_OBJ = TTFont(str(_FONT_PATH))
_GLYPH_SET = _FONT_OBJ.getGlyphSet()
_CMAP = _FONT_OBJ.getBestCmap()
_HMTX = _FONT_OBJ['hmtx'].metrics
_UPM = _FONT_OBJ['head'].unitsPerEm
_FONT_LABELS = {
'GOST_2.30481_type_A': 'GOST Type A',
'GOST_2.30481_type_A_Bold': 'GOST Type A Bold',
'Gost2.30481TypeB': 'GOST Type B',
'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)
ZERO_X = 60.844130
@@ -40,43 +61,243 @@ SMALL_H = 2.6683
_UNIT_AREA_WIDTH = 18.445
_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:
n = max(1, len(unit))
return min(18.7981, _UNIT_AREA_WIDTH / (n * _UNIT_SCALE_X * _UNIT_CHAR_EM))
def _glyph_svg(ch: str, x: float, y: float, fs: float, fill: str, fd: _FontData):
gn = fd.cmap.get(ord(ch))
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:
if v == int(v):
return str(int(v))
return f"{v:.4g}"
def _glyph_ybounds(ch: str, fd: _FontData):
gn = fd.cmap.get(ord(ch))
if gn is None:
return 0.0, 0.0
g = fd.glyf[gn]
if g.numberOfContours == 0:
return 0.0, 0.0
g.recalcBounds(fd.glyf)
return g.yMin / fd.upm, g.yMax / fd.upm
def _text_to_paths(text, x, y, font_size, anchor='start', fill='#1a1a1a', wrap_transform=None):
s = font_size / _UPM
def _extract_braced(s: str, i: int):
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':
total_w = sum(
_HMTX.get(_CMAP.get(ord(ch)), (0, 0))[0] * s
for ch in text if _CMAP.get(ord(ch))
fd.hmtx.get(fd.cmap.get(ord(ch)), (0, 0))[0] * s
for ch in text if fd.cmap.get(ord(ch))
)
x -= total_w / 2
parts = []
cx = x
for ch in text:
gn = _CMAP.get(ord(ch))
gn = fd.cmap.get(ord(ch))
if gn is None:
continue
pen = SVGPathPen(_GLYPH_SET)
_GLYPH_SET[gn].draw(pen)
pen = SVGPathPen(fd.glyph_set)
fd.glyph_set[gn].draw(pen)
d = pen.getCommands()
if d:
t = f'translate({cx:.5f},{y:.5f}) scale({s:.8f},{-s:.8f})'
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)
if wrap_transform:
@@ -84,12 +305,109 @@ 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):
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,
use_paths=True, scale_type='linear', custom_labels=None, label_size=9,
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:
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.")
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)
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:
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 + span * i / big_ticks
big_interval = SCALE_LEN / big_ticks
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)]
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)]
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)]
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 = [
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
@@ -101,7 +419,7 @@ def generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, la
if use_paths:
parts.append(' <g transform="translate(-37.352226,-90.454928)">')
else:
parts += [_FONT_FACE, ' <g transform="translate(-37.352226,-90.454928)">']
parts += [font_face, ' <g transform="translate(-37.352226,-90.454928)">']
parts.append(
' <path style="fill:#ffffff;stroke:#1a1a1a;stroke-width:0.184;'
@@ -110,52 +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" />'
)
if use_paths:
parts += [
' ' + _text_to_paths(
unit, 41.455711, 109.03796, _unit_font_size(unit),
wrap_transform=f'scale({_UNIT_SCALE_X},1.0364155)'
),
' ' + _text_to_paths(range_label, 184.79707, 113.6755, 3.60539),
]
# Paths mode and any unit needing special handling (markup, half-height):
# use _markup_unit_svg which fits actual ink bounds to the box, giving
# correct sizing for all fonts. Text mode for plain normal-height units
# keeps live <text> nodes; half-height always uses paths for correct centering.
if use_paths or _has_markup(unit) or half_height_unit:
unit_svg = ' ' + _markup_unit_svg(unit, fd, box_y0=unit_box_y0)
else:
fs = _unit_font_size(unit)
parts += [
f' <text style="font-size:{fs:.4f}px;font-family:\'ГОСТ тип А\',monospace;fill:#1a1a1a"'
fs = _unit_font_size(unit, fd)
unit_svg = (
f' <text style="font-size:{fs:.4f}px;font-family:\'{fd.label}\',monospace;fill:#1a1a1a"'
f' x="41.455711" y="109.03796"'
f' transform="scale({_UNIT_SCALE_X},1.0364155)">{escape(unit)}</text>',
f' <text style="font-size:3.60539px;font-family:\'ГОСТ тип А\',monospace;fill:#1a1a1a"'
f' transform="scale({_UNIT_SCALE_X},1.0364155)">{escape(unit)}</text>'
)
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>',
]
# Major ticks
for x in big_xs:
parts.append(
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" />'
)
# Minor ticks
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
if scale_type == 'sqrt':
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(
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" />'
)
# 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'))
parts.append(' ' + _text_to_paths(label, x, 101.49598, label_size, fd, anchor='middle'))
else:
parts.append(
f' <text style="font-size:8.89194px;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>'
)
@@ -165,7 +490,9 @@ def generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks, la
@app.route('/')
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'])
@@ -178,8 +505,21 @@ 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'
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
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
label_size = max(0.5, min(25.0, float(request.form.get('label_size', 9))))
try:
svg = generate_svg(unit, min_val, max_val, range_label, big_ticks, small_ticks,
label_count, use_paths, scale_type, custom_labels, label_size,
font_key, half_height_unit)
except ValueError as err:
return Response(str(err), status=400, mimetype='text/plain')
return Response(svg, mimetype='image/svg+xml')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 942 KiB

+118 -10
View File
@@ -301,6 +301,15 @@
<div class="field">
<label>Unit</label>
<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 class="field">
<label>Range label</label>
@@ -311,16 +320,32 @@
<div class="section">
<div class="section-eye">Scale range</div>
<div class="row row-2">
<div class="row row-3">
<div class="field">
<label>Mapping</label>
<div class="mode-toggle" role="group" aria-label="Scale mapping">
<button type="button" class="mode-btn active" data-scale="linear"
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"
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 class="field">
<label>Min value</label>
<input name="min" type="number" step="any" value="0" required>
</div>
<div class="field">
<div class="field" id="field-max">
<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>
<input type="hidden" name="scale_type" id="scale-type" value="linear">
</div>
<div class="section">
@@ -340,6 +365,27 @@
<span style="font-family:var(--f-mono);font-size:0.65rem;color:var(--muted)">(Labels minus one) should divide evenly into Major intervals for best appearance</span>
</div>
</div>
<div class="row row-2" style="margin-top:1.25rem">
<div class="field">
<label>Custom labels (optional)</label>
<input name="labels" type="text" placeholder="e.g. Lo, 25, Mid, 75, Hi — leave blank to use calculated values">
<span style="font-family:var(--f-mono);font-size:0.65rem;color:var(--muted)">Comma-separated. When set, overrides the calculated numbers and its count sets the number of labels.</span>
</div>
<div class="field">
<label>Label size (mm)</label>
<input name="label_size" type="number" step="any" min="0.5" max="25" value="9" required>
</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 class="actions">
@@ -371,6 +417,31 @@
<script>
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() {
const status = document.getElementById('status');
status.textContent = 'Generating…';
@@ -400,13 +471,39 @@
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;
const minInput = document.querySelector('input[name="min"]');
const maxInput = document.querySelector('input[name="max"]');
if (btn.dataset.scale === 'log') {
if (!(parseFloat(minInput.value) > 0)) minInput.value = '1';
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();
});
});
});
@@ -420,6 +517,17 @@
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();
</script>
</body>