OpenMind v1 : cartes mentales libres, local-first et dockerisees
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>
This commit is contained in:
140
scripts/generate_icons.py
Normal file
140
scripts/generate_icons.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user