Premiere version fonctionnelle, demarrable avec `docker compose up -d --build` sur http://localhost:8080. Architecture - Frontend React 18 / TypeScript strict / Vite / Tailwind, canvas @xyflow/react, etat Zustand, persistance IndexedDB via Dexie, auto-layout dagre. - Backend FastAPI / SQLAlchemy 2 / Pydantic v2 / SQLite, qui sert aussi les fichiers statiques du frontend compile. - Image Docker multi-stage, un seul service, bind mount ./data pour SQLite. Local-first Les cartes restent dans le navigateur. Le serveur ne recoit une carte que sur demande explicite de partage. Partage Deux secrets distincts : share_token public en lecture seule dans l'URL, et management_token conserve uniquement cote client, transmis en en-tete pour les modifications et stocke hashe en base. Fonctionnalites Tableau de bord, editeur complet (undo/redo, copier/coller, raccourcis, branches repliables, recherche), 9 themes, 4 dispositions automatiques, panneau de proprietes, import/export .mmap, export PNG/SVG/PDF, impression, page publique en lecture seule, PWA. Qualite 25 tests pytest et 43 tests vitest au vert, ruff et ESLint sans erreur, TypeScript strict sans erreur. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
141 lines
5.1 KiB
Python
141 lines
5.1 KiB
Python
"""Genere les icones PNG de la PWA sans dependance externe.
|
|
|
|
Usage : python scripts/generate_icons.py
|
|
Les fichiers sont ecrits dans frontend/public/icons/.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import struct
|
|
import zlib
|
|
from pathlib import Path
|
|
|
|
OUTPUT_DIR = Path(__file__).resolve().parent.parent / "frontend" / "public" / "icons"
|
|
|
|
INDIGO = (79, 70, 229, 255)
|
|
WHITE = (255, 255, 255, 255)
|
|
LIGHT = (199, 210, 254, 255)
|
|
MID = (165, 180, 252, 255)
|
|
SUPERSAMPLE = 3
|
|
|
|
|
|
class Canvas:
|
|
def __init__(self, size: int) -> None:
|
|
self.size = size
|
|
self.buffer = bytearray(size * size * 4)
|
|
|
|
def set_pixel(self, x: int, y: int, color: tuple[int, int, int, int]) -> None:
|
|
if 0 <= x < self.size and 0 <= y < self.size:
|
|
offset = (y * self.size + x) * 4
|
|
self.buffer[offset : offset + 4] = bytes(color)
|
|
|
|
def fill_rounded_rect(
|
|
self, x0: float, y0: float, x1: float, y1: float, radius: float, color
|
|
) -> None:
|
|
for y in range(int(y0), int(y1)):
|
|
for x in range(int(x0), int(x1)):
|
|
cx = min(max(x, x0 + radius), x1 - radius)
|
|
cy = min(max(y, y0 + radius), y1 - radius)
|
|
if (x - cx) ** 2 + (y - cy) ** 2 <= radius * radius:
|
|
self.set_pixel(x, y, color)
|
|
|
|
def fill_circle(self, cx: float, cy: float, radius: float, color) -> None:
|
|
for y in range(int(cy - radius), int(cy + radius) + 1):
|
|
for x in range(int(cx - radius), int(cx + radius) + 1):
|
|
if (x - cx) ** 2 + (y - cy) ** 2 <= radius * radius:
|
|
self.set_pixel(x, y, color)
|
|
|
|
def draw_segment(self, x0, y0, x1, y1, width, color) -> None:
|
|
half = width / 2
|
|
dx, dy = x1 - x0, y1 - y0
|
|
length_sq = dx * dx + dy * dy or 1
|
|
min_x, max_x = int(min(x0, x1) - half - 1), int(max(x0, x1) + half + 1)
|
|
min_y, max_y = int(min(y0, y1) - half - 1), int(max(y0, y1) + half + 1)
|
|
|
|
for y in range(min_y, max_y + 1):
|
|
for x in range(min_x, max_x + 1):
|
|
t = max(0.0, min(1.0, ((x - x0) * dx + (y - y0) * dy) / length_sq))
|
|
px, py = x0 + t * dx, y0 + t * dy
|
|
if (x - px) ** 2 + (y - py) ** 2 <= half * half:
|
|
self.set_pixel(x, y, color)
|
|
|
|
def downsample(self, factor: int) -> "Canvas":
|
|
target = Canvas(self.size // factor)
|
|
area = factor * factor
|
|
for y in range(target.size):
|
|
for x in range(target.size):
|
|
totals = [0, 0, 0, 0]
|
|
for sy in range(factor):
|
|
row = (y * factor + sy) * self.size
|
|
for sx in range(factor):
|
|
offset = (row + x * factor + sx) * 4
|
|
for channel in range(4):
|
|
totals[channel] += self.buffer[offset + channel]
|
|
target.set_pixel(x, y, tuple(value // area for value in totals))
|
|
return target
|
|
|
|
def to_png(self, path: Path) -> None:
|
|
rows = bytearray()
|
|
stride = self.size * 4
|
|
for y in range(self.size):
|
|
rows.append(0) # filtre "None"
|
|
rows.extend(self.buffer[y * stride : (y + 1) * stride])
|
|
|
|
def chunk(tag: bytes, data: bytes) -> bytes:
|
|
return (
|
|
struct.pack(">I", len(data))
|
|
+ tag
|
|
+ data
|
|
+ struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
|
|
)
|
|
|
|
header = struct.pack(">IIBBBBB", self.size, self.size, 8, 6, 0, 0, 0)
|
|
png = (
|
|
b"\x89PNG\r\n\x1a\n"
|
|
+ chunk(b"IHDR", header)
|
|
+ chunk(b"IDAT", zlib.compress(bytes(rows), 9))
|
|
+ chunk(b"IEND", b"")
|
|
)
|
|
path.write_bytes(png)
|
|
|
|
|
|
def render(size: int, content_scale: float, corner_ratio: float) -> Canvas:
|
|
"""Dessine l'icone : un noeud racine relie a trois branches."""
|
|
canvas = Canvas(size)
|
|
canvas.fill_rounded_rect(0, 0, size, size, size * corner_ratio, INDIGO)
|
|
|
|
unit = size / 512 * content_scale
|
|
center = size / 2
|
|
offset = center - 256 * unit
|
|
|
|
def point(x: float, y: float) -> tuple[float, float]:
|
|
return (offset + x * unit, offset + y * unit)
|
|
|
|
root = point(168, 256)
|
|
targets = [point(336, 160), point(336, 256), point(336, 352)]
|
|
for target in targets:
|
|
canvas.draw_segment(root[0], root[1], target[0], target[1], 18 * unit, WHITE)
|
|
|
|
canvas.fill_circle(root[0], root[1], 46 * unit, WHITE)
|
|
for index, target in enumerate(targets):
|
|
canvas.fill_circle(target[0], target[1], 30 * unit, MID if index == 1 else LIGHT)
|
|
return canvas
|
|
|
|
|
|
def build(size: int, filename: str, content_scale: float, corner_ratio: float) -> None:
|
|
large = render(size * SUPERSAMPLE, content_scale, corner_ratio)
|
|
large.downsample(SUPERSAMPLE).to_png(OUTPUT_DIR / filename)
|
|
print(f"ecrit {filename} ({size}x{size})")
|
|
|
|
|
|
def main() -> None:
|
|
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
build(192, "icon-192.png", 1.0, 0.22)
|
|
build(512, "icon-512.png", 1.0, 0.22)
|
|
# Icone maskable : contenu reduit pour rester dans la zone sure.
|
|
build(512, "icon-maskable-512.png", 0.62, 0.5)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|