feat: Playtube Stream Dock Plugin 1.0.0 (Ajazz AKP153E) mit Icon-Pack
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
// Playtube-Marke (siehe Playtube tools/generate_icon.py): Verlauf violett -> pink, weisses Symbol.
|
||||
export const BRAND_FROM = [124, 58, 237];
|
||||
export const BRAND_TO = [236, 72, 153];
|
||||
export const WHITE = '#ffffff';
|
||||
export const DIM_BG = '#2b2540';
|
||||
export const DIM_GLYPH = '#9b93b8';
|
||||
export const DARK_BG = '#171b24';
|
||||
|
||||
/** Diagonaler Verlauf ueber eine Leinwand der Kantenlaenge `size` (Farbfunktion fuer Canvas.fill). */
|
||||
export function brandGradient(size) {
|
||||
return (x, y) => {
|
||||
const t = Math.max(0, Math.min(1, (x + y) / (2 * size)));
|
||||
return BRAND_FROM.map((from, i) => Math.round(from + (BRAND_TO[i] - from) * t));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
// Erzeugt manifest.json, Uebersetzungen (en/de) und alle PNG-Bilder des Plugins.
|
||||
// Aufruf: npm run assets
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Canvas } from './raster.mjs';
|
||||
import { drawGlyph } from './glyphs.mjs';
|
||||
import { DIM_BG, DIM_GLYPH, WHITE, brandGradient } from './brand.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const PLUGIN_DIR = path.join(ROOT, 'com.fojadrachi.playtube.sdPlugin');
|
||||
const PREFIX = 'com.fojadrachi.playtube.';
|
||||
const VERSION = JSON.parse(readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
|
||||
|
||||
const KEY_SIZE = 144;
|
||||
const ICON_SIZE = 80;
|
||||
const PLUGIN_ICON_SIZE = 256;
|
||||
|
||||
/**
|
||||
* states: Reihenfolge = State-Index im Plugin (siehe actions.js).
|
||||
* on = Symbol weiss auf Akzentflaeche (aktiv), dim = gedaempfte Farbe (aus).
|
||||
* titled: Taste zeigt Text (Lautstaerke, Name ...) -> Symbol rutscht nach oben.
|
||||
*/
|
||||
const ACTIONS = [
|
||||
{ id: 'playpause', accent: 'playback', titled: true, states: [{ glyph: 'play' }, { glyph: 'pause' }],
|
||||
en: ['Play/Pause', 'Toggle playback'], de: ['Wiedergabe/Pause', 'Wiedergabe starten oder pausieren'] },
|
||||
{ id: 'previous', accent: 'playback', states: [{ glyph: 'previous' }],
|
||||
en: ['Previous track', 'Go to the previous track'], de: ['Vorheriger Titel', 'Zum vorherigen Titel springen'] },
|
||||
{ id: 'next', accent: 'playback', states: [{ glyph: 'next' }],
|
||||
en: ['Next track', 'Skip to the next track'], de: ['Nächster Titel', 'Zum nächsten Titel springen'] },
|
||||
{ id: 'seekback', accent: 'playback', titled: true, states: [{ glyph: 'rewind' }],
|
||||
en: ['Rewind', 'Jump back a few seconds'], de: ['Zurückspulen', 'Ein paar Sekunden zurückspringen'] },
|
||||
{ id: 'seekforward', accent: 'playback', titled: true, states: [{ glyph: 'forward' }],
|
||||
en: ['Fast forward', 'Jump forward a few seconds'], de: ['Vorspulen', 'Ein paar Sekunden vorspringen'] },
|
||||
{ id: 'volumeup', accent: 'volume', titled: true, states: [{ glyph: 'volup' }],
|
||||
en: ['Volume up', 'Louder (hold to keep going)'], de: ['Lauter', 'Lauter (gedrückt halten für Dauer-Anpassung)'] },
|
||||
{ id: 'volumedown', accent: 'volume', titled: true, states: [{ glyph: 'voldown' }],
|
||||
en: ['Volume down', 'Quieter (hold to keep going)'], de: ['Leiser', 'Leiser (gedrückt halten für Dauer-Anpassung)'] },
|
||||
{ id: 'volumeset', accent: 'volume', titled: true, states: [{ glyph: 'vol' }],
|
||||
en: ['Set volume', 'Jump to a fixed volume'], de: ['Lautstärke setzen', 'Auf eine feste Lautstärke springen'] },
|
||||
{ id: 'mute', accent: 'volume', titled: true, states: [{ glyph: 'vol' }, { glyph: 'mute', on: true }],
|
||||
en: ['Mute', 'Mute or unmute'], de: ['Stummschalten', 'Ton aus- oder einschalten'] },
|
||||
{ id: 'nowplaying', accent: 'playback', titled: true, states: [{ glyph: 'note' }], en: ['Now playing', 'Cover and title, press to play/pause'],
|
||||
de: ['Aktueller Titel', 'Cover und Titel, Drücken = Wiedergabe/Pause'] },
|
||||
{ id: 'like', accent: 'like', states: [{ glyph: 'heart' }, { glyph: 'heartfill', on: true }],
|
||||
en: ['Like', 'Like the current track'], de: ['Gefällt mir', 'Aktuellen Titel mit „Gefällt mir“ markieren'] },
|
||||
{ id: 'shuffle', accent: 'misc', states: [{ glyph: 'shuffle' }],
|
||||
en: ['Shuffle', 'Toggle shuffle'], de: ['Zufallswiedergabe', 'Zufallswiedergabe umschalten'] },
|
||||
{ id: 'repeat', accent: 'misc',
|
||||
states: [{ glyph: 'repeat', dim: true }, { glyph: 'repeat', on: true }, { glyph: 'repeatone', on: true }],
|
||||
en: ['Repeat', 'Cycle repeat mode'], de: ['Wiederholen', 'Wiederholungsmodus wechseln'] },
|
||||
{ id: 'playlist', accent: 'playlist', titled: true, states: [{ glyph: 'playlist' }],
|
||||
en: ['Play playlist', 'Start a playlist from YouTube Music'],
|
||||
de: ['Playlist abspielen', 'Eine Playlist aus YouTube Music starten'] },
|
||||
{ id: 'switchtab', accent: 'misc', titled: true, states: [{ glyph: 'tabs' }],
|
||||
en: ['Switch tab', 'Switch between YouTube and YouTube Music'],
|
||||
de: ['Tab wechseln', 'Zwischen YouTube und YouTube Musik wechseln'] },
|
||||
{ id: 'show', accent: 'misc', states: [{ glyph: 'window' }],
|
||||
en: ['Show Playtube', 'Bring the Playtube window to the front'],
|
||||
de: ['Playtube anzeigen', 'Das Playtube-Fenster in den Vordergrund holen'] },
|
||||
];
|
||||
|
||||
function keyImage(action, state) {
|
||||
const canvas = new Canvas(KEY_SIZE);
|
||||
const background = state.dim ? DIM_BG : brandGradient(KEY_SIZE);
|
||||
canvas.roundRect(0, 0, KEY_SIZE, KEY_SIZE, 0, background);
|
||||
// Symbol immer exakt mittig; bei Tasten mit Text etwas kleiner, damit der Text darunter Platz hat.
|
||||
const r = action.titled ? 34 : 44;
|
||||
drawGlyph(canvas, state.glyph, KEY_SIZE / 2, KEY_SIZE / 2, r, state.dim ? DIM_GLYPH : WHITE, background);
|
||||
return canvas.png();
|
||||
}
|
||||
|
||||
function iconImage(action) {
|
||||
const canvas = new Canvas(ICON_SIZE);
|
||||
const background = brandGradient(ICON_SIZE);
|
||||
canvas.roundRect(0, 0, ICON_SIZE, ICON_SIZE, 18, background);
|
||||
drawGlyph(canvas, action.states[0].glyph, ICON_SIZE / 2, ICON_SIZE / 2, 26, WHITE, background);
|
||||
return canvas.png();
|
||||
}
|
||||
|
||||
function pluginIcon() {
|
||||
const canvas = new Canvas(PLUGIN_ICON_SIZE);
|
||||
const background = brandGradient(PLUGIN_ICON_SIZE);
|
||||
canvas.roundRect(0, 0, PLUGIN_ICON_SIZE, PLUGIN_ICON_SIZE, 56, background);
|
||||
drawGlyph(canvas, 'play', PLUGIN_ICON_SIZE / 2, PLUGIN_ICON_SIZE / 2, 84, WHITE, background);
|
||||
return canvas.png();
|
||||
}
|
||||
|
||||
function write(relative, content) {
|
||||
const target = path.join(PLUGIN_DIR, relative);
|
||||
mkdirSync(path.dirname(target), { recursive: true });
|
||||
writeFileSync(target, content);
|
||||
}
|
||||
|
||||
function manifest() {
|
||||
return {
|
||||
Actions: ACTIONS.map((action) => ({
|
||||
Icon: `static/icon/${action.id}@2x.png`,
|
||||
Name: action.en[0],
|
||||
DisableAutomaticStates: true,
|
||||
States: action.states.map((_, index) => ({
|
||||
Image: `static/btn/${action.id}_${index}.png`,
|
||||
TitleAlignment: 'bottom',
|
||||
FontSize: '11',
|
||||
FontStyle: 'Bold',
|
||||
TitleColor: '#ffffff',
|
||||
})),
|
||||
Settings: {},
|
||||
Controllers: ['Keypad', 'Information'],
|
||||
UserTitleEnabled: false,
|
||||
SupportedInMultiActions: true,
|
||||
Tooltip: action.en[1],
|
||||
UUID: `${PREFIX}${action.id}`,
|
||||
PropertyInspectorPath: 'propertyInspector/index.html',
|
||||
})),
|
||||
SDKVersion: 1,
|
||||
Author: 'Playtube',
|
||||
Name: 'Playtube',
|
||||
Icon: 'static/icon/[email protected]',
|
||||
CodePathWin: 'plugin/index.js',
|
||||
Description: 'Control Playtube (YouTube / YouTube Music) from your Stream Dock',
|
||||
Category: 'Playtube',
|
||||
CategoryIcon: 'static/icon/[email protected]',
|
||||
Version: VERSION,
|
||||
OS: [{ Platform: 'windows', MinimumVersion: '10' }],
|
||||
Software: { MinimumVersion: '3.10.188.226' },
|
||||
Nodejs: { Version: '20' },
|
||||
};
|
||||
}
|
||||
|
||||
function locale(language, description) {
|
||||
const entries = Object.fromEntries(ACTIONS.map((action) => {
|
||||
const [name, tooltip] = action[language];
|
||||
return [`${PREFIX}${action.id}`, { Name: name, Tooltip: tooltip }];
|
||||
}));
|
||||
return { Name: 'Playtube', Description: description, Category: 'Playtube', ...entries };
|
||||
}
|
||||
|
||||
function main() {
|
||||
write('manifest.json', `${JSON.stringify(manifest(), null, 2)}\n`);
|
||||
write('en.json', `${JSON.stringify(locale('en', 'Control Playtube (YouTube / YouTube Music)'), null, 2)}\n`);
|
||||
write('de.json', `${JSON.stringify(locale('de', 'Playtube (YouTube / YouTube Musik) steuern'), null, 2)}\n`);
|
||||
write('static/icon/[email protected]', pluginIcon());
|
||||
for (const action of ACTIONS) {
|
||||
write(`static/icon/${action.id}@2x.png`, iconImage(action));
|
||||
action.states.forEach((state, index) => write(`static/btn/${action.id}_${index}.png`, keyImage(action, state)));
|
||||
}
|
||||
process.stdout.write(`Assets fuer ${ACTIONS.length} Aktionen nach ${PLUGIN_DIR} geschrieben\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,119 @@
|
||||
// Erzeugt das Playtube-Icon-Pack (PNG, 288x288) in zwei Varianten und packt es als Zip.
|
||||
// Aufruf: npm run iconpack
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { copyFileSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { Canvas } from './raster.mjs';
|
||||
import { drawGlyph, GLYPHS } from './glyphs.mjs';
|
||||
import { drawExtra, EXTRA_GLYPHS } from './glyphs-extra.mjs';
|
||||
import { drawApp, APP_GLYPHS } from './glyphs-apps.mjs';
|
||||
import { DARK_BG, WHITE, brandGradient } from './brand.mjs';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const PACK_NAME = 'Playtube-Icons';
|
||||
const PACK_DIR = path.join(ROOT, 'iconpack', PACK_NAME);
|
||||
const ZIP = path.join(ROOT, 'dist', `${PACK_NAME}.zip`);
|
||||
const SIZE = 288;
|
||||
const GLYPH_RADIUS = SIZE * 0.31;
|
||||
|
||||
const README = `Playtube Icon Pack
|
||||
==================
|
||||
|
||||
Ordner:
|
||||
gradient/ Playtube-Look: Verlauf violett -> pink, weisses Symbol
|
||||
dark/ dunkler Hintergrund, Symbol im Playtube-Verlauf
|
||||
|
||||
Alle Bilder: PNG, 288 x 288 Pixel (das Geraet nutzt 144 x 144, die doppelte Groesse bleibt beim
|
||||
Verkleinern scharf).
|
||||
|
||||
Benutzen in der Stream-Dock-Software:
|
||||
Taste anklicken -> das Vorschaubild der Taste (oben links im Einstellungsbereich) anklicken ->
|
||||
"Bild waehlen" / Ordner-Symbol -> eine PNG-Datei aus diesem Pack auswaehlen.
|
||||
`;
|
||||
|
||||
function render(variant, drawer, name) {
|
||||
const canvas = new Canvas(SIZE);
|
||||
const gradient = brandGradient(SIZE);
|
||||
const background = variant === 'gradient' ? gradient : DARK_BG;
|
||||
canvas.roundRect(0, 0, SIZE, SIZE, 0, background);
|
||||
drawer(canvas, name, SIZE / 2, SIZE / 2, GLYPH_RADIUS, variant === 'gradient' ? WHITE : gradient, background);
|
||||
return canvas.png();
|
||||
}
|
||||
|
||||
const SD_PACK_DIR = path.join(ROOT, 'iconpack', 'com.fojadrachi.playtube.sdIconPack');
|
||||
// Deutsche Suchbegriffe zusaetzlich zu den englischen Symbolnamen.
|
||||
const GERMAN_TAGS = {
|
||||
folder: ['ordner'], file: ['datei'], home: ['start', 'zuhause'], gear: ['einstellungen', 'zahnrad'],
|
||||
plus: ['hinzufuegen'], minus: ['entfernen'], close: ['schliessen'], check: ['haken', 'ok'], star: ['stern', 'favorit'],
|
||||
search: ['suche', 'lupe'], power: ['ein', 'aus'], mic: ['mikrofon'], headphones: ['kopfhoerer'], lock: ['schloss'],
|
||||
bell: ['glocke', 'benachrichtigung'], clock: ['uhr', 'zeit'], calendar: ['kalender'], mail: ['post', 'email'],
|
||||
globe: ['welt', 'internet'], download: ['herunterladen'], upload: ['hochladen'], trash: ['papierkorb', 'loeschen'],
|
||||
eye: ['auge', 'anzeigen'], stop: ['stopp'], refresh: ['aktualisieren', 'neu laden'], wifi: ['wlan'], grid: ['raster'],
|
||||
menu: ['menue'], more: ['mehr'], info: ['information'], warning: ['warnung'], monitor: ['bildschirm'],
|
||||
sun: ['sonne', 'hell'], moon: ['mond', 'dunkel'], play: ['abspielen'], pause: ['pause'], next: ['weiter', 'naechster'],
|
||||
previous: ['zurueck', 'vorheriger'], volup: ['lauter'], voldown: ['leiser'], vol: ['lautstaerke'], mute: ['stumm'],
|
||||
heart: ['herz', 'gefaellt mir'], shuffle: ['zufall'], repeat: ['wiederholen'], playlist: ['liste'],
|
||||
'eye-off': ['auge', 'ausblenden', 'verstecken'], lightbulb: ['gluehbirne', 'licht', 'lampe'],
|
||||
'pc-monitor': ['pc', 'computer', 'system', 'monitor', 'leistung'], chrome: ['browser', 'google'],
|
||||
discord: ['chat', 'voice'], steam: ['spiele', 'games'], twitch: ['stream', 'live'], netflix: ['serien', 'filme'],
|
||||
youtube: ['video'], medal: ['clips', 'aufnahme'], nvidia: ['grafikkarte', 'gpu'], steelseries: ['sonar', 'audio'],
|
||||
aniworld: ['anime'], prismlens: ['filter', 'kamera'],
|
||||
};
|
||||
|
||||
/** Stream-Dock-Icon-Pack (.sdIconPack): manifest.json, icons.json, icon.png und icons/*.png. */
|
||||
function buildStreamDockPack(jobs) {
|
||||
rmSync(SD_PACK_DIR, { recursive: true, force: true });
|
||||
mkdirSync(path.join(SD_PACK_DIR, 'icons'), { recursive: true });
|
||||
mkdirSync(path.join(SD_PACK_DIR, 'previews'), { recursive: true });
|
||||
const entries = [];
|
||||
for (const variant of ['gradient', 'dark']) {
|
||||
for (const { name } of jobs) {
|
||||
const file = `${variant}_${name}.png`;
|
||||
copyFileSync(path.join(PACK_DIR, variant, `${name}.png`), path.join(SD_PACK_DIR, 'icons', file));
|
||||
const base = name.split('-')[0];
|
||||
entries.push({
|
||||
path: file,
|
||||
name: `${name.replace(/-/g, ' ')} (${variant})`,
|
||||
tags: ['playtube', variant, ...name.split('-'), ...(GERMAN_TAGS[name] || GERMAN_TAGS[base] || [])],
|
||||
});
|
||||
}
|
||||
}
|
||||
writeFileSync(path.join(SD_PACK_DIR, 'icons.json'), `${JSON.stringify(entries, null, 2)}\n`);
|
||||
writeFileSync(path.join(SD_PACK_DIR, 'manifest.json'), `${JSON.stringify({
|
||||
Name: 'Playtube Icons',
|
||||
Version: '1.0.0',
|
||||
Description: 'Icons im Playtube-Look (Verlauf violett/pink): Ordner, Pfeile, Player-Symbole und mehr.',
|
||||
Author: 'Playtube',
|
||||
Icon: 'icon.png',
|
||||
}, null, 2)}\n`);
|
||||
const logo = render('gradient', drawGlyph, 'play');
|
||||
writeFileSync(path.join(SD_PACK_DIR, 'icon.png'), logo);
|
||||
writeFileSync(path.join(SD_PACK_DIR, 'previews', 'preview1.png'), logo);
|
||||
}
|
||||
|
||||
function main() {
|
||||
rmSync(PACK_DIR, { recursive: true, force: true });
|
||||
const jobs = [
|
||||
...GLYPHS.map((name) => ({ name, drawer: drawGlyph })),
|
||||
...EXTRA_GLYPHS.map((name) => ({ name, drawer: drawExtra })),
|
||||
...APP_GLYPHS.map((name) => ({ name, drawer: drawApp })),
|
||||
];
|
||||
for (const variant of ['gradient', 'dark']) {
|
||||
mkdirSync(path.join(PACK_DIR, variant), { recursive: true });
|
||||
for (const { name, drawer } of jobs) {
|
||||
writeFileSync(path.join(PACK_DIR, variant, `${name}.png`), render(variant, drawer, name));
|
||||
}
|
||||
}
|
||||
writeFileSync(path.join(PACK_DIR, 'LIESMICH.txt'), README);
|
||||
buildStreamDockPack(jobs);
|
||||
|
||||
mkdirSync(path.dirname(ZIP), { recursive: true });
|
||||
rmSync(ZIP, { force: true });
|
||||
// Unter Windows das mitgelieferte bsdtar (Git-Bash-GNU-tar liest "C:" als Netzwerk-Host).
|
||||
const tar = process.platform === 'win32' ? path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'tar.exe') : 'tar';
|
||||
execFileSync(tar, ['-a', '-c', '-f', ZIP, '-C', path.dirname(PACK_DIR), PACK_NAME], { stdio: 'inherit' });
|
||||
process.stdout.write(`${jobs.length} Symbole x 2 Varianten -> ${PACK_DIR}\nZip: ${ZIP}\n`);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,129 @@
|
||||
// Vereinfachte App-/Geraete-Symbole (eigene Zeichnungen, keine Original-Logos) in Einheitskoordinaten [-1, 1].
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
export const APP_GLYPHS = [
|
||||
'eye-off', 'lightbulb', 'pc-monitor',
|
||||
'chrome', 'discord', 'steam', 'twitch', 'netflix', 'youtube', 'medal', 'nvidia', 'steelseries', 'aniworld',
|
||||
'prismlens',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {import('./raster.mjs').Canvas} canvas
|
||||
* @param {string} name - Eintrag aus APP_GLYPHS
|
||||
* @param {number} cx
|
||||
* @param {number} cy
|
||||
* @param {number} r - halbe Symbolgroesse in Pixeln
|
||||
* @param {string|Function} color
|
||||
* @param {string|Function} background - Farbe fuer "ausgeschnittene" Teile
|
||||
*/
|
||||
export function drawApp(canvas, name, cx, cy, r, color, background) {
|
||||
const p = (x, y) => [cx + x * r, cy + y * r];
|
||||
const stroke = (x1, y1, x2, y2, w = 0.2, paint = color) => {
|
||||
canvas.line(...p(x1, y1), ...p(x2, y2), w * r, paint);
|
||||
};
|
||||
const path = (points, w = 0.2, paint = color) => {
|
||||
points.slice(1).forEach((pt, i) => stroke(...points[i], ...pt, w, paint));
|
||||
};
|
||||
const poly = (points, paint = color) => canvas.polygon(points.map(([x, y]) => p(x, y)), paint);
|
||||
const disc = (x, y, radius, paint = color) => canvas.circle(...p(x, y), radius * r, paint);
|
||||
const ring = (x, y, outer, inner, from = 0, to = TAU) => canvas.ring(...p(x, y), outer * r, inner * r, color, from, to);
|
||||
const box = (x, y, w, h, radius, paint = color) => canvas.roundRect(...p(x, y), w * r, h * r, radius * r, paint);
|
||||
// Ellipse; thickness = Randstaerke relativ zum Radius, null = gefuellt.
|
||||
const ellipse = (x, y, rx, ry, thickness, paint = color) => {
|
||||
const [ex, ey] = p(x, y);
|
||||
canvas.fill((px, py) => {
|
||||
const d = Math.hypot((px - ex) / (rx * r), (py - ey) / (ry * r));
|
||||
return d <= 1 && (thickness === null || d >= 1 - thickness);
|
||||
}, paint, [ex - rx * r, ey - ry * r, ex + rx * r, ey + ry * r]);
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case 'eye-off':
|
||||
ellipse(0, 0, 0.98, 0.58, 0.22);
|
||||
disc(0, 0, 0.3);
|
||||
stroke(-0.75, 0.8, 0.75, -0.8, 0.42, background);
|
||||
stroke(-0.75, 0.8, 0.75, -0.8, 0.2);
|
||||
break;
|
||||
case 'lightbulb':
|
||||
disc(0, -0.25, 0.62);
|
||||
poly([[-0.4, 0.1], [0.4, 0.1], [0.28, 0.45], [-0.28, 0.45]]);
|
||||
box(-0.3, 0.5, 0.6, 0.16, 0.06);
|
||||
box(-0.2, 0.72, 0.4, 0.14, 0.06);
|
||||
path([[-0.2, 0.05], [0, -0.3], [0.2, 0.05]], 0.1, background);
|
||||
break;
|
||||
case 'pc-monitor':
|
||||
box(-0.95, -0.8, 1.9, 1.35, 0.12);
|
||||
box(-0.8, -0.65, 1.6, 1.05, 0.05, background);
|
||||
path([[-0.6, -0.1], [-0.28, -0.1], [-0.12, -0.45], [0.08, 0.2], [0.24, -0.2], [0.6, -0.2]], 0.13);
|
||||
stroke(0, 0.55, 0, 0.82, 0.2);
|
||||
stroke(-0.5, 0.88, 0.5, 0.88, 0.2);
|
||||
break;
|
||||
case 'chrome':
|
||||
ring(0, 0, 0.95, 0.72);
|
||||
disc(0, 0, 0.36);
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
const a = (-90 + i * 120) * (Math.PI / 180);
|
||||
stroke(Math.cos(a) * 0.3, Math.sin(a) * 0.3, Math.cos(a) * 0.85, Math.sin(a) * 0.85, 0.2);
|
||||
}
|
||||
disc(0, 0, 0.2, background);
|
||||
break;
|
||||
case 'discord':
|
||||
ellipse(0, -0.05, 0.95, 0.7, null);
|
||||
poly([[-0.5, 0.45], [-0.75, 0.9], [-0.15, 0.62]]);
|
||||
poly([[0.5, 0.45], [0.75, 0.9], [0.15, 0.62]]);
|
||||
disc(-0.36, -0.05, 0.19, background);
|
||||
disc(0.36, -0.05, 0.19, background);
|
||||
break;
|
||||
case 'steam':
|
||||
ring(0, 0, 0.95, 0.72);
|
||||
disc(0.32, -0.28, 0.32);
|
||||
disc(0.32, -0.28, 0.14, background);
|
||||
disc(-0.36, 0.3, 0.2);
|
||||
stroke(-0.36, 0.3, 0.2, -0.05, 0.16);
|
||||
break;
|
||||
case 'twitch':
|
||||
poly([[-0.85, -0.85], [0.9, -0.85], [0.9, 0.4], [0.4, 0.4], [0.05, 0.8], [0.05, 0.4], [-0.85, 0.4]]);
|
||||
stroke(0.02, -0.5, 0.02, 0, 0.16, background);
|
||||
stroke(0.5, -0.5, 0.5, 0, 0.16, background);
|
||||
break;
|
||||
case 'netflix':
|
||||
box(-0.55, -0.85, 0.28, 1.7, 0.02);
|
||||
box(0.27, -0.85, 0.28, 1.7, 0.02);
|
||||
poly([[-0.55, -0.85], [-0.27, -0.85], [0.55, 0.85], [0.27, 0.85]]);
|
||||
break;
|
||||
case 'youtube':
|
||||
box(-0.95, -0.62, 1.9, 1.24, 0.34);
|
||||
poly([[-0.2, -0.32], [0.34, 0], [-0.2, 0.32]], background);
|
||||
break;
|
||||
case 'medal':
|
||||
poly([[0, -0.95], [0.85, -0.6], [0.85, 0.15], [0, 0.95], [-0.85, 0.15], [-0.85, -0.6]]);
|
||||
path([[-0.45, 0.32], [-0.45, -0.32], [0, 0.1], [0.45, -0.32], [0.45, 0.32]], 0.16, background);
|
||||
break;
|
||||
case 'nvidia':
|
||||
box(-0.95, -0.55, 1.9, 1.1, 0.12);
|
||||
disc(0, 0, 0.42, background);
|
||||
disc(0, 0, 0.17);
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const a = i * (Math.PI / 2) + 0.4;
|
||||
stroke(Math.cos(a) * 0.14, Math.sin(a) * 0.14, Math.cos(a) * 0.36, Math.sin(a) * 0.36, 0.12);
|
||||
}
|
||||
break;
|
||||
case 'steelseries':
|
||||
ring(0, -0.42, 0.56, 0.34, Math.PI / 2, TAU);
|
||||
ring(0, 0.42, 0.56, 0.34, (3 * Math.PI) / 2, TAU);
|
||||
ring(0, 0.42, 0.56, 0.34, 0, Math.PI);
|
||||
break;
|
||||
case 'aniworld':
|
||||
ring(0, 0, 0.95, 0.72);
|
||||
poly([[-0.22, -0.42], [0.5, 0], [-0.22, 0.42]]);
|
||||
break;
|
||||
case 'prismlens':
|
||||
ring(0, 0, 0.95, 0.75);
|
||||
ring(0, 0, 0.58, 0.4);
|
||||
disc(0, 0, 0.2);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unbekanntes Symbol: ${name}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
// Allgemeine Symbole (Ordner, Pfeile, Einstellungen ...) fuer das Icon-Pack, in Einheitskoordinaten [-1, 1].
|
||||
|
||||
const TAU = Math.PI * 2;
|
||||
|
||||
export const EXTRA_GLYPHS = [
|
||||
'folder', 'file', 'home', 'gear', 'plus', 'minus', 'close', 'check', 'star', 'search', 'power', 'mic',
|
||||
'headphones', 'lock', 'bell', 'clock', 'calendar', 'mail', 'globe', 'download', 'upload', 'trash', 'eye',
|
||||
'stop', 'refresh', 'wifi', 'grid', 'menu', 'more', 'info', 'warning', 'monitor', 'sun', 'moon',
|
||||
'arrow-up', 'arrow-down', 'arrow-left', 'arrow-right', 'chevron-up', 'chevron-down', 'chevron-left',
|
||||
'chevron-right',
|
||||
];
|
||||
|
||||
const ARROW_ANGLES = { 'arrow-right': 0, 'arrow-down': 90, 'arrow-left': 180, 'arrow-up': 270 };
|
||||
const CHEVRON_ANGLES = { 'chevron-right': 0, 'chevron-down': 90, 'chevron-left': 180, 'chevron-up': 270 };
|
||||
|
||||
function starPoints() {
|
||||
const points = [];
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const angle = -Math.PI / 2 + (i * Math.PI) / 5;
|
||||
const radius = i % 2 === 0 ? 0.98 : 0.42;
|
||||
points.push([Math.cos(angle) * radius, Math.sin(angle) * radius + 0.05]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
const STAR = starPoints();
|
||||
|
||||
/**
|
||||
* @param {import('./raster.mjs').Canvas} canvas
|
||||
* @param {string} name - Eintrag aus EXTRA_GLYPHS
|
||||
* @param {number} cx
|
||||
* @param {number} cy
|
||||
* @param {number} r - halbe Symbolgroesse in Pixeln
|
||||
* @param {string|Function} color
|
||||
* @param {string|Function} background - Farbe fuer "ausgeschnittene" Teile
|
||||
*/
|
||||
export function drawExtra(canvas, name, cx, cy, r, color, background) {
|
||||
const p = (x, y) => [cx + x * r, cy + y * r];
|
||||
const rot = (x, y, degrees) => {
|
||||
const a = (degrees * Math.PI) / 180;
|
||||
return [x * Math.cos(a) - y * Math.sin(a), x * Math.sin(a) + y * Math.cos(a)];
|
||||
};
|
||||
const stroke = (x1, y1, x2, y2, w = 0.2, paint = color, degrees = 0) => {
|
||||
const [ax, ay] = p(...rot(x1, y1, degrees));
|
||||
const [bx, by] = p(...rot(x2, y2, degrees));
|
||||
canvas.line(ax, ay, bx, by, w * r, paint);
|
||||
};
|
||||
const path = (points, w = 0.2, degrees = 0) => {
|
||||
points.slice(1).forEach((pt, i) => stroke(...points[i], ...pt, w, color, degrees));
|
||||
};
|
||||
const poly = (points, paint = color) => canvas.polygon(points.map(([x, y]) => p(x, y)), paint);
|
||||
const disc = (x, y, radius, paint = color) => canvas.circle(...p(x, y), radius * r, paint);
|
||||
const ring = (x, y, outer, inner, from = 0, to = TAU) => canvas.ring(...p(x, y), outer * r, inner * r, color, from, to);
|
||||
const box = (x, y, w, h, radius, paint = color) => canvas.roundRect(...p(x, y), w * r, h * r, radius * r, paint);
|
||||
// Ellipse; thickness = Randstaerke relativ zum Radius, null = gefuellt.
|
||||
const ellipse = (rx, ry, thickness) => {
|
||||
const [ex, ey] = p(0, 0);
|
||||
canvas.fill((px, py) => {
|
||||
const d = Math.hypot((px - ex) / (rx * r), (py - ey) / (ry * r));
|
||||
return d <= 1 && (thickness === null || d >= 1 - thickness);
|
||||
}, color, [ex - rx * r, ey - ry * r, ex + rx * r, ey + ry * r]);
|
||||
};
|
||||
|
||||
if (name in ARROW_ANGLES) {
|
||||
const a = ARROW_ANGLES[name];
|
||||
stroke(-0.8, 0, 0.8, 0, 0.2, color, a);
|
||||
stroke(0.8, 0, 0.2, -0.6, 0.2, color, a);
|
||||
stroke(0.8, 0, 0.2, 0.6, 0.2, color, a);
|
||||
return;
|
||||
}
|
||||
if (name in CHEVRON_ANGLES) {
|
||||
path([[-0.3, -0.75], [0.35, 0], [-0.3, 0.75]], 0.24, CHEVRON_ANGLES[name]);
|
||||
return;
|
||||
}
|
||||
|
||||
switch (name) {
|
||||
case 'folder':
|
||||
poly([[-0.92, -0.5], [-0.92, -0.78], [-0.15, -0.78], [0.1, -0.5]]);
|
||||
box(-0.92, -0.5, 1.84, 1.3, 0.12);
|
||||
break;
|
||||
case 'file':
|
||||
poly([[-0.62, -0.92], [0.18, -0.92], [0.68, -0.42], [0.68, 0.92], [-0.62, 0.92]]);
|
||||
poly([[0.18, -0.92], [0.18, -0.42], [0.68, -0.42]], background);
|
||||
stroke(-0.3, 0.05, 0.36, 0.05, 0.14, background);
|
||||
stroke(-0.3, 0.45, 0.36, 0.45, 0.14, background);
|
||||
break;
|
||||
case 'home':
|
||||
poly([[-1, 0], [0, -0.9], [1, 0]]);
|
||||
box(-0.62, -0.05, 1.24, 0.9, 0.06);
|
||||
box(-0.16, 0.25, 0.32, 0.6, 0.04, background);
|
||||
break;
|
||||
case 'gear':
|
||||
ring(0, 0, 0.66, 0.3);
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const a = (i * 45 * Math.PI) / 180;
|
||||
stroke(Math.cos(a) * 0.6, Math.sin(a) * 0.6, Math.cos(a) * 0.9, Math.sin(a) * 0.9, 0.3);
|
||||
}
|
||||
break;
|
||||
case 'plus':
|
||||
stroke(-0.8, 0, 0.8, 0, 0.24);
|
||||
stroke(0, -0.8, 0, 0.8, 0.24);
|
||||
break;
|
||||
case 'minus':
|
||||
stroke(-0.8, 0, 0.8, 0, 0.24);
|
||||
break;
|
||||
case 'close':
|
||||
stroke(-0.7, -0.7, 0.7, 0.7, 0.24);
|
||||
stroke(-0.7, 0.7, 0.7, -0.7, 0.24);
|
||||
break;
|
||||
case 'check':
|
||||
path([[-0.8, 0.05], [-0.25, 0.6], [0.85, -0.55]], 0.26);
|
||||
break;
|
||||
case 'star':
|
||||
poly(STAR);
|
||||
break;
|
||||
case 'search':
|
||||
ring(-0.2, -0.2, 0.68, 0.44);
|
||||
stroke(0.3, 0.3, 0.82, 0.82, 0.28);
|
||||
break;
|
||||
case 'power':
|
||||
ring(0, 0.08, 0.8, 0.58, (3 * Math.PI) / 2 + 0.65, TAU);
|
||||
ring(0, 0.08, 0.8, 0.58, 0, (3 * Math.PI) / 2 - 0.65);
|
||||
stroke(0, -0.9, 0, -0.1, 0.24);
|
||||
break;
|
||||
case 'mic':
|
||||
box(-0.28, -0.9, 0.56, 1.1, 0.28);
|
||||
ring(0, -0.05, 0.6, 0.42, 0, Math.PI);
|
||||
stroke(0, 0.55, 0, 0.9, 0.16);
|
||||
stroke(-0.35, 0.9, 0.35, 0.9, 0.16);
|
||||
break;
|
||||
case 'headphones':
|
||||
ring(0, 0.05, 0.85, 0.62, Math.PI, TAU);
|
||||
box(-0.95, 0.05, 0.4, 0.7, 0.12);
|
||||
box(0.55, 0.05, 0.4, 0.7, 0.12);
|
||||
break;
|
||||
case 'lock':
|
||||
ring(0, -0.15, 0.45, 0.25, Math.PI, TAU);
|
||||
stroke(-0.35, -0.15, -0.35, 0.05, 0.2);
|
||||
stroke(0.35, -0.15, 0.35, 0.05, 0.2);
|
||||
box(-0.65, 0, 1.3, 0.9, 0.12);
|
||||
disc(0, 0.4, 0.13, background);
|
||||
break;
|
||||
case 'bell':
|
||||
disc(0, -0.15, 0.55);
|
||||
poly([[-0.55, -0.15], [0.55, -0.15], [0.82, 0.5], [-0.82, 0.5]]);
|
||||
disc(0, 0.72, 0.16);
|
||||
stroke(0, -0.85, 0, -0.65, 0.16);
|
||||
break;
|
||||
case 'clock':
|
||||
ring(0, 0, 0.92, 0.7);
|
||||
stroke(0, 0, 0, -0.5, 0.15);
|
||||
stroke(0, 0, 0.36, 0.22, 0.15);
|
||||
break;
|
||||
case 'calendar':
|
||||
box(-0.88, -0.68, 1.76, 1.58, 0.12);
|
||||
box(-0.72, -0.22, 1.44, 1.0, 0.05, background);
|
||||
stroke(-0.4, -0.92, -0.4, -0.5, 0.18);
|
||||
stroke(0.4, -0.92, 0.4, -0.5, 0.18);
|
||||
break;
|
||||
case 'mail':
|
||||
box(-0.92, -0.62, 1.84, 1.24, 0.12);
|
||||
stroke(-0.8, -0.5, 0, 0.12, 0.15, background);
|
||||
stroke(0, 0.12, 0.8, -0.5, 0.15, background);
|
||||
break;
|
||||
case 'globe':
|
||||
ring(0, 0, 0.92, 0.75);
|
||||
ellipse(0.42, 0.92, 0.18);
|
||||
stroke(-0.92, 0, 0.92, 0, 0.13);
|
||||
break;
|
||||
case 'download':
|
||||
stroke(0, -0.9, 0, 0.3, 0.22);
|
||||
path([[-0.5, -0.2], [0, 0.4], [0.5, -0.2]], 0.22);
|
||||
path([[-0.85, 0.35], [-0.85, 0.85], [0.85, 0.85], [0.85, 0.35]], 0.2);
|
||||
break;
|
||||
case 'upload':
|
||||
stroke(0, 0.3, 0, -0.9, 0.22);
|
||||
path([[-0.5, -0.4], [0, -0.95], [0.5, -0.4]], 0.22);
|
||||
path([[-0.85, 0.35], [-0.85, 0.85], [0.85, 0.85], [0.85, 0.35]], 0.2);
|
||||
break;
|
||||
case 'trash':
|
||||
box(-0.6, -0.3, 1.2, 1.2, 0.1);
|
||||
stroke(-0.85, -0.5, 0.85, -0.5, 0.2);
|
||||
stroke(-0.25, -0.75, 0.25, -0.75, 0.16);
|
||||
stroke(-0.2, -0.05, -0.2, 0.65, 0.13, background);
|
||||
stroke(0.2, -0.05, 0.2, 0.65, 0.13, background);
|
||||
break;
|
||||
case 'eye':
|
||||
ellipse(0.98, 0.58, 0.22);
|
||||
disc(0, 0, 0.3);
|
||||
break;
|
||||
case 'stop':
|
||||
box(-0.72, -0.72, 1.44, 1.44, 0.16);
|
||||
break;
|
||||
case 'refresh': {
|
||||
const end = 5.5;
|
||||
ring(0, 0, 0.85, 0.6, 0.7, end);
|
||||
const bx = Math.cos(end) * 0.725;
|
||||
const by = Math.sin(end) * 0.725;
|
||||
poly([[bx - Math.sin(end) * 0.55, by + Math.cos(end) * 0.55],
|
||||
[bx + Math.cos(end) * 0.5, by + Math.sin(end) * 0.5],
|
||||
[bx - Math.cos(end) * 0.5, by - Math.sin(end) * 0.5]]);
|
||||
break;
|
||||
}
|
||||
case 'wifi':
|
||||
for (const radius of [0.45, 0.8, 1.15]) ring(0, 0.6, radius + 0.09, radius - 0.09, 1.25 * Math.PI, 1.75 * Math.PI);
|
||||
disc(0, 0.6, 0.13);
|
||||
break;
|
||||
case 'grid':
|
||||
for (const x of [-0.85, 0.08]) for (const y of [-0.85, 0.08]) box(x, y, 0.77, 0.77, 0.14);
|
||||
break;
|
||||
case 'menu':
|
||||
stroke(-0.8, -0.5, 0.8, -0.5, 0.22);
|
||||
stroke(-0.8, 0, 0.8, 0, 0.22);
|
||||
stroke(-0.8, 0.5, 0.8, 0.5, 0.22);
|
||||
break;
|
||||
case 'more':
|
||||
for (const x of [-0.6, 0, 0.6]) disc(x, 0, 0.17);
|
||||
break;
|
||||
case 'info':
|
||||
ring(0, 0, 0.92, 0.72);
|
||||
disc(0, -0.42, 0.11);
|
||||
stroke(0, -0.12, 0, 0.5, 0.18);
|
||||
break;
|
||||
case 'warning':
|
||||
poly([[0, -0.9], [0.98, 0.78], [-0.98, 0.78]]);
|
||||
stroke(0, -0.3, 0, 0.25, 0.16, background);
|
||||
disc(0, 0.52, 0.09, background);
|
||||
break;
|
||||
case 'monitor':
|
||||
box(-0.92, -0.72, 1.84, 1.2, 0.12);
|
||||
box(-0.78, -0.58, 1.56, 0.92, 0.05, background);
|
||||
stroke(0, 0.5, 0, 0.8, 0.2);
|
||||
stroke(-0.45, 0.85, 0.45, 0.85, 0.2);
|
||||
break;
|
||||
case 'sun':
|
||||
disc(0, 0, 0.42);
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const a = (i * 45 * Math.PI) / 180;
|
||||
stroke(Math.cos(a) * 0.68, Math.sin(a) * 0.68, Math.cos(a) * 0.95, Math.sin(a) * 0.95, 0.17);
|
||||
}
|
||||
break;
|
||||
case 'moon':
|
||||
disc(0, 0, 0.8);
|
||||
disc(0.38, -0.3, 0.68, background);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unbekanntes Symbol: ${name}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// Symbole fuer die Tasten, gezeichnet in Einheitskoordinaten [-1, 1] um (cx, cy) mit Radius r.
|
||||
|
||||
function heartPoints() {
|
||||
const points = [];
|
||||
for (let i = 0; i < 72; i += 1) {
|
||||
const t = (i / 72) * Math.PI * 2;
|
||||
points.push([
|
||||
(16 * Math.sin(t) ** 3) / 17,
|
||||
-(13 * Math.cos(t) - 5 * Math.cos(2 * t) - 2 * Math.cos(3 * t) - Math.cos(4 * t)) / 17,
|
||||
]);
|
||||
}
|
||||
return points;
|
||||
}
|
||||
const HEART = heartPoints();
|
||||
|
||||
export const GLYPHS = [
|
||||
'play', 'pause', 'next', 'previous', 'rewind', 'forward', 'volup', 'voldown', 'vol', 'mute',
|
||||
'note', 'heart', 'heartfill', 'shuffle', 'repeat', 'repeatone', 'playlist', 'tabs', 'window',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {import('./raster.mjs').Canvas} canvas
|
||||
* @param {string} name - Eintrag aus GLYPHS
|
||||
* @param {number} cx
|
||||
* @param {number} cy
|
||||
* @param {number} r - halbe Symbolgroesse in Pixeln
|
||||
* @param {string} color - Symbolfarbe
|
||||
* @param {string} background - Farbe hinter dem Symbol (fuer "ausgeschnittene" Formen)
|
||||
*/
|
||||
export function drawGlyph(canvas, name, cx, cy, r, color, background) {
|
||||
const p = (x, y) => [cx + x * r, cy + y * r];
|
||||
const poly = (points) => canvas.polygon(points.map(([x, y]) => p(x, y)), color);
|
||||
const stroke = (x1, y1, x2, y2, w = 0.2) => {
|
||||
const [ax, ay] = p(x1, y1);
|
||||
const [bx, by] = p(x2, y2);
|
||||
canvas.line(ax, ay, bx, by, w * r, color);
|
||||
};
|
||||
const path = (points, w = 0.2) => points.slice(1).forEach((pt, i) => stroke(...points[i], ...pt, w));
|
||||
const speaker = (dx = 0) => poly([[-0.9 + dx, -0.3], [-0.5 + dx, -0.3], [-0.1 + dx, -0.7],
|
||||
[-0.1 + dx, 0.7], [-0.5 + dx, 0.3], [-0.9 + dx, 0.3]]);
|
||||
const arc = (dx, outer, inner) => {
|
||||
const [ax, ay] = p(dx, 0);
|
||||
canvas.ring(ax, ay, outer * r, inner * r, color, Math.PI * 2 - 0.75, Math.PI * 2);
|
||||
canvas.ring(ax, ay, outer * r, inner * r, color, 0, 0.75);
|
||||
};
|
||||
const heart = (scale, fill) => canvas.polygon(
|
||||
HEART.map(([x, y]) => [cx + x * r * scale, cy + (y * scale + 0.05) * r]), fill);
|
||||
|
||||
switch (name) {
|
||||
case 'play':
|
||||
poly([[-0.7, -0.85], [0.75, 0], [-0.7, 0.85]]);
|
||||
break;
|
||||
case 'pause':
|
||||
poly([[-0.65, -0.8], [-0.2, -0.8], [-0.2, 0.8], [-0.65, 0.8]]);
|
||||
poly([[0.2, -0.8], [0.65, -0.8], [0.65, 0.8], [0.2, 0.8]]);
|
||||
break;
|
||||
case 'next':
|
||||
poly([[-0.8, -0.75], [0.4, 0], [-0.8, 0.75]]);
|
||||
poly([[0.55, -0.75], [0.85, -0.75], [0.85, 0.75], [0.55, 0.75]]);
|
||||
break;
|
||||
case 'previous':
|
||||
poly([[0.8, -0.75], [-0.4, 0], [0.8, 0.75]]);
|
||||
poly([[-0.55, -0.75], [-0.85, -0.75], [-0.85, 0.75], [-0.55, 0.75]]);
|
||||
break;
|
||||
case 'rewind':
|
||||
poly([[0.05, -0.65], [-0.55, 0], [0.05, 0.65]]);
|
||||
poly([[0.85, -0.65], [0.25, 0], [0.85, 0.65]]);
|
||||
break;
|
||||
case 'forward':
|
||||
poly([[-0.05, -0.65], [0.55, 0], [-0.05, 0.65]]);
|
||||
poly([[-0.85, -0.65], [-0.25, 0], [-0.85, 0.65]]);
|
||||
break;
|
||||
case 'volup':
|
||||
speaker(-0.1);
|
||||
stroke(0.3, 0, 0.9, 0);
|
||||
stroke(0.6, -0.3, 0.6, 0.3);
|
||||
break;
|
||||
case 'voldown':
|
||||
speaker(-0.1);
|
||||
stroke(0.3, 0, 0.9, 0);
|
||||
break;
|
||||
case 'vol':
|
||||
speaker(-0.2);
|
||||
arc(-0.3, 0.75, 0.55);
|
||||
arc(-0.3, 1.1, 0.9);
|
||||
break;
|
||||
case 'mute':
|
||||
speaker(-0.1);
|
||||
stroke(0.3, -0.35, 0.9, 0.35);
|
||||
stroke(0.3, 0.35, 0.9, -0.35);
|
||||
break;
|
||||
case 'note': {
|
||||
const [nx, ny] = p(-0.35, 0.5);
|
||||
canvas.circle(nx, ny, 0.38 * r, color);
|
||||
stroke(0.0, 0.45, 0.0, -0.85, 0.2);
|
||||
stroke(0.0, -0.85, 0.7, -0.5, 0.2);
|
||||
break;
|
||||
}
|
||||
case 'heart':
|
||||
heart(1, color);
|
||||
heart(0.62, background);
|
||||
break;
|
||||
case 'heartfill':
|
||||
heart(1, color);
|
||||
break;
|
||||
case 'shuffle':
|
||||
path([[-0.9, 0.5], [-0.3, 0.5], [0.3, -0.5], [0.55, -0.5]], 0.18);
|
||||
path([[-0.9, -0.5], [-0.3, -0.5], [0.3, 0.5], [0.55, 0.5]], 0.18);
|
||||
poly([[0.5, -0.85], [0.95, -0.5], [0.5, -0.15]]);
|
||||
poly([[0.5, 0.15], [0.95, 0.5], [0.5, 0.85]]);
|
||||
break;
|
||||
case 'repeat':
|
||||
case 'repeatone':
|
||||
path([[-0.8, 0.15], [-0.8, -0.4], [0.3, -0.4]], 0.18);
|
||||
poly([[0.3, -0.8], [0.9, -0.4], [0.3, 0]]);
|
||||
path([[0.8, -0.15], [0.8, 0.4], [-0.3, 0.4]], 0.18);
|
||||
poly([[-0.3, 0.8], [-0.9, 0.4], [-0.3, 0]]);
|
||||
if (name === 'repeatone') {
|
||||
stroke(0.0, -0.18, 0.0, 0.2, 0.14);
|
||||
stroke(-0.13, -0.1, 0.0, -0.18, 0.14);
|
||||
}
|
||||
break;
|
||||
case 'playlist':
|
||||
stroke(-0.85, -0.55, 0.15, -0.55, 0.18);
|
||||
stroke(-0.85, -0.05, 0.15, -0.05, 0.18);
|
||||
stroke(-0.85, 0.45, -0.25, 0.45, 0.18);
|
||||
poly([[0.3, 0.05], [0.95, 0.45], [0.3, 0.85]]);
|
||||
break;
|
||||
case 'tabs':
|
||||
canvas.roundRect(...p(-0.85, -0.7), 1.1 * r, 0.9 * r, 0.12 * r, color);
|
||||
canvas.roundRect(...p(-0.75, -0.6), 0.9 * r, 0.7 * r, 0.06 * r, background);
|
||||
canvas.roundRect(...p(-0.25, -0.15), 1.1 * r, 0.9 * r, 0.12 * r, color);
|
||||
break;
|
||||
case 'window':
|
||||
canvas.roundRect(...p(-0.9, -0.7), 1.8 * r, 1.4 * r, 0.14 * r, color);
|
||||
canvas.roundRect(...p(-0.75, -0.25), 1.5 * r, 0.85 * r, 0.05 * r, background);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unbekanntes Symbol: ${name}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// Winziger Software-Rasterizer + PNG-Encoder (keine Abhaengigkeiten), fuer die Tasten-Icons.
|
||||
import { deflateSync } from 'node:zlib';
|
||||
|
||||
const SUPERSAMPLE = 4;
|
||||
|
||||
let crcTable = null;
|
||||
function crc32(buffer) {
|
||||
if (!crcTable) {
|
||||
crcTable = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n += 1) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
crcTable[n] = c >>> 0;
|
||||
}
|
||||
}
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of buffer) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function chunk(type, data) {
|
||||
const head = Buffer.alloc(8);
|
||||
head.writeUInt32BE(data.length, 0);
|
||||
head.write(type, 4, 'ascii');
|
||||
const tail = Buffer.alloc(4);
|
||||
tail.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
|
||||
return Buffer.concat([head, data, tail]);
|
||||
}
|
||||
|
||||
export function encodePng(width, height, rgba) {
|
||||
const header = Buffer.alloc(13);
|
||||
header.writeUInt32BE(width, 0);
|
||||
header.writeUInt32BE(height, 4);
|
||||
header[8] = 8; // Bit-Tiefe
|
||||
header[9] = 6; // RGBA
|
||||
const stride = width * 4 + 1;
|
||||
const rows = Buffer.alloc(stride * height);
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
Buffer.from(rgba.buffer, rgba.byteOffset + y * width * 4, width * 4).copy(rows, y * stride + 1);
|
||||
}
|
||||
return Buffer.concat([
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||
chunk('IHDR', header),
|
||||
chunk('IDAT', deflateSync(rows, { level: 9 })),
|
||||
chunk('IEND', Buffer.alloc(0)),
|
||||
]);
|
||||
}
|
||||
|
||||
export function parseColor(hex) {
|
||||
const value = hex.replace('#', '');
|
||||
return [0, 2, 4].map((i) => Number.parseInt(value.slice(i, i + 2), 16));
|
||||
}
|
||||
|
||||
/** Leinwand mit Kantenglaettung; Formen werden per Punkt-in-Form-Test mit Supersampling gefuellt. */
|
||||
export class Canvas {
|
||||
constructor(size) {
|
||||
this.size = size;
|
||||
this.data = new Uint8ClampedArray(size * size * 4);
|
||||
}
|
||||
|
||||
/** contains(x, y) -> boolean in Pixel-Koordinaten; bounds = [x0, y0, x1, y1] begrenzt die Suche. */
|
||||
fill(contains, color, bounds = [0, 0, this.size, this.size]) {
|
||||
// color: "#rrggbb", [r, g, b] oder Funktion (x, y) => [r, g, b] (Farbverlaeufe)
|
||||
const colorAt = typeof color === 'function' ? color
|
||||
: ((fixed) => () => fixed)(typeof color === 'string' ? parseColor(color) : color);
|
||||
const clamp = (v) => Math.max(0, Math.min(this.size, v));
|
||||
const x0 = clamp(Math.floor(bounds[0]));
|
||||
const y0 = clamp(Math.floor(bounds[1]));
|
||||
const x1 = clamp(Math.ceil(bounds[2]));
|
||||
const y1 = clamp(Math.ceil(bounds[3]));
|
||||
const total = SUPERSAMPLE * SUPERSAMPLE;
|
||||
for (let y = y0; y < y1; y += 1) {
|
||||
for (let x = x0; x < x1; x += 1) {
|
||||
let hits = 0;
|
||||
for (let sy = 0; sy < SUPERSAMPLE; sy += 1) {
|
||||
for (let sx = 0; sx < SUPERSAMPLE; sx += 1) {
|
||||
if (contains(x + (sx + 0.5) / SUPERSAMPLE, y + (sy + 0.5) / SUPERSAMPLE)) hits += 1;
|
||||
}
|
||||
}
|
||||
if (hits) this.blend((y * this.size + x) * 4, colorAt(x + 0.5, y + 0.5), hits / total);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
blend(index, [r, g, b], alpha) {
|
||||
const dstA = this.data[index + 3] / 255;
|
||||
const outA = alpha + dstA * (1 - alpha);
|
||||
[r, g, b].forEach((value, offset) => {
|
||||
this.data[index + offset] = (value * alpha + this.data[index + offset] * dstA * (1 - alpha)) / outA;
|
||||
});
|
||||
this.data[index + 3] = outA * 255;
|
||||
}
|
||||
|
||||
roundRect(x, y, w, h, radius, color) {
|
||||
this.fill((px, py) => {
|
||||
const cx = Math.max(x + radius, Math.min(x + w - radius, px));
|
||||
const cy = Math.max(y + radius, Math.min(y + h - radius, py));
|
||||
return px >= x && px <= x + w && py >= y && py <= y + h && (px - cx) ** 2 + (py - cy) ** 2 <= radius ** 2;
|
||||
}, color, [x, y, x + w, y + h]);
|
||||
}
|
||||
|
||||
circle(cx, cy, radius, color) {
|
||||
this.fill((px, py) => (px - cx) ** 2 + (py - cy) ** 2 <= radius ** 2, color,
|
||||
[cx - radius, cy - radius, cx + radius, cy + radius]);
|
||||
}
|
||||
|
||||
/** Kreisring bzw. Bogen (Winkel in Radiant, 0 = rechts, im Uhrzeigersinn). */
|
||||
ring(cx, cy, outer, inner, color, from = 0, to = Math.PI * 2) {
|
||||
this.fill((px, py) => {
|
||||
const d = (px - cx) ** 2 + (py - cy) ** 2;
|
||||
if (d > outer ** 2 || d < inner ** 2) return false;
|
||||
let angle = Math.atan2(py - cy, px - cx);
|
||||
if (angle < 0) angle += Math.PI * 2;
|
||||
return angle >= from && angle <= to;
|
||||
}, color, [cx - outer, cy - outer, cx + outer, cy + outer]);
|
||||
}
|
||||
|
||||
polygon(points, color) {
|
||||
const xs = points.map((p) => p[0]);
|
||||
const ys = points.map((p) => p[1]);
|
||||
this.fill((px, py) => {
|
||||
let inside = false;
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
|
||||
const [xi, yi] = points[i];
|
||||
const [xj, yj] = points[j];
|
||||
if (yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}, color, [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)]);
|
||||
}
|
||||
|
||||
/** Strecke mit Breite `width` und runden Enden. */
|
||||
line(x1, y1, x2, y2, width, color) {
|
||||
const half = width / 2;
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
this.fill((px, py) => {
|
||||
const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (dx * dx + dy * dy || 1)));
|
||||
return (px - (x1 + t * dx)) ** 2 + (py - (y1 + t * dy)) ** 2 <= half * half;
|
||||
}, color, [Math.min(x1, x2) - half, Math.min(y1, y2) - half, Math.max(x1, x2) + half, Math.max(y1, y2) + half]);
|
||||
}
|
||||
|
||||
png() {
|
||||
return encodePng(this.size, this.size, this.data);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user