feat(pwa): иконка приложения — фирменный знак парка
Была нарисованная «карта» на кремовом фоне из первых набросков. Теперь знак «Премиум Парк» из фирменного набора, на белой подложке сайта. Собирается из вектора скриптом scripts/gen-icons.py (три пути, команды M/L/H/V/C, сглаживание сверхдискретизацией ×4) — прежний gen-icons.mjs рисовал плитку вручную и к логотипу отношения не имел. Готовый PNG из набора не подошёл: знак внутри него занимает 264×195 из 512, и на иконке он бы размылился. Отдельная maskable-версия с полями: Android режет иконку по кругу, и знак без запаса теряет края. Ссылка apple-touch-icon в index.html — iOS манифест не читает и берёт иконку «на экран Домой» только оттуда. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,9 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#EEF1F5" />
|
<meta name="theme-color" content="#EEF1F5" />
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png" />
|
||||||
|
<!-- iOS манифест не читает: иконку «на экран Домой» он берёт только отсюда. -->
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
|
||||||
<link rel="manifest" href="/manifest.webmanifest" />
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 909 B After Width: | Height: | Size: 7.7 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 21 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -7,7 +7,8 @@
|
|||||||
"background_color": "#EEF1F5",
|
"background_color": "#EEF1F5",
|
||||||
"theme_color": "#EEF1F5",
|
"theme_color": "#EEF1F5",
|
||||||
"icons": [
|
"icons": [
|
||||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
|
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||||
|
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
// Генерация PWA-иконок «Премиум Водитель» без сторонних либ (zlib PNG-энкодер).
|
|
||||||
// Дизайн: кремовый фон #F3EEE3, тёмная скруглённая плитка #1C1B19,
|
|
||||||
// внутри белая «карта» #F6F2E9 с тёмной полосой (финансовый мотив). Без жёлтого/текста.
|
|
||||||
import zlib from "node:zlib";
|
|
||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
const CREAM = [243, 238, 227];
|
|
||||||
const INK = [28, 27, 25];
|
|
||||||
const CARD = [246, 242, 233];
|
|
||||||
|
|
||||||
function crc32(buf) {
|
|
||||||
let c = ~0;
|
|
||||||
for (let i = 0; i < buf.length; i++) {
|
|
||||||
c ^= buf[i];
|
|
||||||
for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
|
|
||||||
}
|
|
||||||
return (~c) >>> 0;
|
|
||||||
}
|
|
||||||
function chunk(type, data) {
|
|
||||||
const t = Buffer.from(type, "ascii");
|
|
||||||
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
|
|
||||||
const body = Buffer.concat([t, data]);
|
|
||||||
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body), 0);
|
|
||||||
return Buffer.concat([len, body, crc]);
|
|
||||||
}
|
|
||||||
function encodePng(N, rgba) {
|
|
||||||
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
|
|
||||||
const ihdr = Buffer.alloc(13);
|
|
||||||
ihdr.writeUInt32BE(N, 0); ihdr.writeUInt32BE(N, 4); ihdr[8] = 8; ihdr[9] = 6;
|
|
||||||
const stride = N * 4;
|
|
||||||
const raw = Buffer.alloc(N * (stride + 1));
|
|
||||||
for (let y = 0; y < N; y++) {
|
|
||||||
raw[y * (stride + 1)] = 0;
|
|
||||||
rgba.copy(raw, y * (stride + 1) + 1, y * stride, y * stride + stride);
|
|
||||||
}
|
|
||||||
return Buffer.concat([sig, chunk("IHDR", ihdr), chunk("IDAT", zlib.deflateSync(raw)), chunk("IEND", Buffer.alloc(0))]);
|
|
||||||
}
|
|
||||||
function inRoundRect(px, py, x0, y0, x1, y1, r) {
|
|
||||||
if (px < x0 || px >= x1 || py < y0 || py >= y1) return false;
|
|
||||||
const cx = px < x0 + r ? x0 + r : px >= x1 - r ? x1 - r : px;
|
|
||||||
const cy = py < y0 + r ? y0 + r : py >= y1 - r ? y1 - r : py;
|
|
||||||
const dx = px - cx, dy = py - cy;
|
|
||||||
return dx * dx + dy * dy <= r * r;
|
|
||||||
}
|
|
||||||
|
|
||||||
function draw(N) {
|
|
||||||
const buf = Buffer.alloc(N * N * 4);
|
|
||||||
// tile (centered ~64%)
|
|
||||||
const tile = Math.round(N * 0.64), tx0 = Math.round((N - tile) / 2), ty0 = tx0;
|
|
||||||
const tx1 = tx0 + tile, ty1 = ty0 + tile, tr = Math.round(tile * 0.24);
|
|
||||||
// card inside tile
|
|
||||||
const cw = Math.round(tile * 0.62), ch = Math.round(tile * 0.42);
|
|
||||||
const cx0 = Math.round((N - cw) / 2), cy0 = Math.round((N - ch) / 2);
|
|
||||||
const cx1 = cx0 + cw, cy1 = cy0 + ch, cr = Math.round(ch * 0.18);
|
|
||||||
// stripe near top of card
|
|
||||||
const sy0 = cy0 + Math.round(ch * 0.22), sy1 = sy0 + Math.round(ch * 0.14);
|
|
||||||
for (let y = 0; y < N; y++) {
|
|
||||||
for (let x = 0; x < N; x++) {
|
|
||||||
let col = CREAM;
|
|
||||||
if (inRoundRect(x, y, tx0, ty0, tx1, ty1, tr)) col = INK;
|
|
||||||
if (inRoundRect(x, y, cx0, cy0, cx1, cy1, cr)) col = CARD;
|
|
||||||
if (x >= cx0 && x < cx1 && y >= sy0 && y < sy1) col = INK;
|
|
||||||
const o = (y * N + x) * 4;
|
|
||||||
buf[o] = col[0]; buf[o + 1] = col[1]; buf[o + 2] = col[2]; buf[o + 3] = 255;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return encodePng(N, buf);
|
|
||||||
}
|
|
||||||
|
|
||||||
const dir = path.resolve("public/icons");
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
for (const N of [192, 512]) {
|
|
||||||
fs.writeFileSync(path.join(dir, `icon-${N}.png`), draw(N));
|
|
||||||
console.log(`wrote icon-${N}.png`);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
"""Иконки приложения из фирменного знака «Премиум Парк».
|
||||||
|
|
||||||
|
Знак — вектор (assets/logos/premium_park_favicon_pack/favicon.svg, поле 64×64,
|
||||||
|
чёрные штрихи + янтарная дуга). Растеризуем его сами: путей всего три, команды —
|
||||||
|
только M/L/H/V/C/Z, а тянуть в проект зависимость ради этого не хочется.
|
||||||
|
|
||||||
|
Сглаживание — сверхдискретизацией ×4: рисуем крупнее и уменьшаем. Так края
|
||||||
|
получаются чище, чем у любой заливки «в лоб».
|
||||||
|
|
||||||
|
Запуск (нужен Pillow):
|
||||||
|
python scripts/gen-icons.py
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
SVG = Path(r"C:\VibeCoding\assets\logos\premium_park_favicon_pack\favicon.svg")
|
||||||
|
OUT = Path(__file__).resolve().parent.parent / "public" / "icons"
|
||||||
|
BG = (255, 255, 255, 255) # --s-paper сайта: знак нарисован для светлой подложки
|
||||||
|
SS = 4 # кратность сверхдискретизации
|
||||||
|
# Полезная часть знака внутри поля 64×64 (замерено по путям).
|
||||||
|
MARK = (11.0, 8.0, 59.4, 43.7)
|
||||||
|
|
||||||
|
|
||||||
|
def _curve(p0, p1, p2, p3, steps=24):
|
||||||
|
for i in range(1, steps + 1):
|
||||||
|
t = i / steps
|
||||||
|
u = 1 - t
|
||||||
|
yield (u*u*u*p0[0] + 3*u*u*t*p1[0] + 3*u*t*t*p2[0] + t*t*t*p3[0],
|
||||||
|
u*u*u*p0[1] + 3*u*u*t*p1[1] + 3*u*t*t*p2[1] + t*t*t*p3[1])
|
||||||
|
|
||||||
|
|
||||||
|
def flatten(d: str) -> list[tuple[float, float]]:
|
||||||
|
"""Путь SVG → многоугольник. Поддержаны M, L, H, V, C, Z — больше в знаке нет."""
|
||||||
|
tokens = re.findall(r"[MLHVCZmlhvcz]|-?\d*\.?\d+", d)
|
||||||
|
pts: list[tuple[float, float]] = []
|
||||||
|
cur = (0.0, 0.0)
|
||||||
|
cmd = ""
|
||||||
|
i = 0
|
||||||
|
while i < len(tokens):
|
||||||
|
t = tokens[i]
|
||||||
|
if t.isalpha():
|
||||||
|
cmd = t
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
n = lambda k: float(tokens[i + k])
|
||||||
|
if cmd in "ML":
|
||||||
|
cur = (n(0), n(1)); pts.append(cur); i += 2
|
||||||
|
elif cmd == "H":
|
||||||
|
cur = (n(0), cur[1]); pts.append(cur); i += 1
|
||||||
|
elif cmd == "V":
|
||||||
|
cur = (cur[0], n(0)); pts.append(cur); i += 1
|
||||||
|
elif cmd == "C":
|
||||||
|
p1, p2, p3 = (n(0), n(1)), (n(2), n(3)), (n(4), n(5))
|
||||||
|
pts.extend(_curve(cur, p1, p2, p3)); cur = p3; i += 6
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
return pts
|
||||||
|
|
||||||
|
|
||||||
|
def paths() -> list[tuple[list[tuple[float, float]], str]]:
|
||||||
|
svg = SVG.read_text(encoding="utf-8")
|
||||||
|
out = []
|
||||||
|
for d, fill in re.findall(r'<path d="([^"]+)"\s+fill="([^"]+)"', svg):
|
||||||
|
out.append((flatten(d), fill))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def compose(size: int, inset: float) -> Image.Image:
|
||||||
|
"""inset — доля стороны под знак. Для maskable нужна безопасная зона:
|
||||||
|
Android режет иконку по кругу, и знак без запаса теряет края."""
|
||||||
|
big = size * SS
|
||||||
|
img = Image.new("RGBA", (big, big), BG)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
x0, y0, x1, y1 = MARK
|
||||||
|
scale = min(big * inset / (x1 - x0), big * inset / (y1 - y0))
|
||||||
|
dx = (big - (x1 - x0) * scale) / 2 - x0 * scale
|
||||||
|
dy = (big - (y1 - y0) * scale) / 2 - y0 * scale
|
||||||
|
for pts, fill in paths():
|
||||||
|
draw.polygon([(x * scale + dx, y * scale + dy) for x, y in pts], fill=fill)
|
||||||
|
return img.resize((size, size), Image.LANCZOS)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
OUT.mkdir(parents=True, exist_ok=True)
|
||||||
|
for name, size, inset in (("icon-192.png", 192, 0.82),
|
||||||
|
("icon-512.png", 512, 0.82),
|
||||||
|
("icon-maskable-512.png", 512, 0.60),
|
||||||
|
("apple-touch-icon.png", 180, 0.82)):
|
||||||
|
compose(size, inset).save(OUT / name)
|
||||||
|
print("собрано", name)
|
||||||
Reference in New Issue
Block a user