/* One Piece Wiki — original, procedurally generated portrait/icon artwork.
Every character gets a role-themed line-art medallion; every Devil Fruit gets a
stylized fruit-shape icon colored by type. All shapes are drawn from scratch here —
no external art or copyrighted imagery is used anywhere in this file. */
function radialLines(cx, cy, rInner, rOuter, count, strokeWidth) {
let out = '';
for (let i = 0; i < count; i++) {
const a = (Math.PI * 2 * i) / count;
const x1 = cx + rInner * Math.cos(a), y1 = cy + rInner * Math.sin(a);
const x2 = cx + rOuter * Math.cos(a), y2 = cy + rOuter * Math.sin(a);
out += ``;
}
return out;
}
function starPoints(cx, cy, rOuter, rInner, count) {
const pts = [];
for (let i = 0; i < count * 2; i++) {
const r = i % 2 === 0 ? rOuter : rInner;
const a = (Math.PI * i) / count - Math.PI / 2;
pts.push(`${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`);
}
return pts.join(' ');
}
/* Simple original line-art built from primitive shapes (circles/lines/polygons) — one per archetype. */
const ROLE_ICONS = {
sword: '',
compass: `${radialLines(32, 32, 15, 18, 12, 2)}`,
target: '',
cross: '',
scroll: '',
gear: `${radialLines(32, 32, 12, 18, 8, 3)}`,
note: '',
chefhat: '',
flask: '',
anchor: '',
crown: '',
tiara: '',
flag: '',
dagger: '',
skull: '',
paw: '',
wing: '',
star: ``,
};
const ROLE_ICON_RULES = [
[/navigat/, 'compass'],
[/sniper|marksman|sharpshoot/, 'target'],
[/doctor|medic|surgeon/, 'cross'],
[/archaeolog|scholar/, 'scroll'],
[/shipwright|engineer|mechanic/, 'gear'],
[/musician|singer/, 'note'],
[/cook|chef/, 'chefhat'],
[/scientist/, 'flask'],
[/helmsman|admiral|marine|navy|warden/, 'anchor'],
[/king|emperor|empress|yonko|shogun/, 'crown'],
[/princess|prince/, 'tiara'],
[/revolutionary/, 'flag'],
[/agent|spy|officer|assassin|cp0|cp9/, 'dagger'],
[/mink|beast|tiger|lion|leopard|wolf|dragon/, 'paw'],
[/phoenix|bird|wing|flight/, 'wing'],
[/swordsm|santoryu|blade|commander|combatant/, 'sword'],
[/captain|warlord|pirate/, 'skull'],
];
function iconKeyForCharacter(c) {
const haystack = `${c.role} ${c.epithet || ''} ${c.affiliation}`.toLowerCase();
for (const [re, key] of ROLE_ICON_RULES) {
if (re.test(haystack)) return key;
}
return 'star';
}
function characterImageTitle(c) {
return c.imageTitle || c.name;
}
function fruitImageTitle(f) {
if (f.imageTitle) return f.imageTitle;
if (f.id === 'gomu-gomu-no-mi') return 'Hito Hito no Mi, Model: Nika';
return f.name;
}
function properImageHTML(kind, title, label, fallback) {
return `
${fallback}
`;
}
function characterPortraitFallbackSVG(c) {
const accent = accentFor(c);
const hue2 = (hashHue(c.id + 'x') + 45) % 360;
const icon = ROLE_ICONS[iconKeyForCharacter(c)] || ROLE_ICONS.star;
const uid = c.id.replace(/[^a-z0-9]/g, '');
const rotation = hashHue(c.name) % 90;
const fruit = c.devilFruit ? findFruit(c.devilFruit) : null;
return `
`;
}
const CATEGORY_HEX = { Paramecia: '#a06cd5', Zoan: '#4c9a5a', Logia: '#3f9bd0' };
function fruitPortraitFallbackSVG(f) {
const color = CATEGORY_HEX[f.category] || '#e3b04b';
const glow = f.subtype && /mythical|special/i.test(f.subtype);
const uid = f.id.replace(/[^a-z0-9]/g, '');
return `
`;
}
function characterPortraitSVG(c) {
return properImageHTML('character', characterImageTitle(c), `${c.name} portrait`, characterPortraitFallbackSVG(c));
}
function fruitPortraitSVG(f) {
return properImageHTML('fruit', fruitImageTitle(f), `${f.name} fruit`, fruitPortraitFallbackSVG(f));
}
const IMAGE_CACHE_KEY = 'opwikiProperImages:v1';
const IMAGE_API_ENDPOINT = 'https://onepiece.fandom.com/api.php';
let properImageCache = null;
function getProperImageCache() {
if (properImageCache) return properImageCache;
try {
properImageCache = JSON.parse(localStorage.getItem(IMAGE_CACHE_KEY) || '{}');
} catch (_) {
properImageCache = {};
}
return properImageCache;
}
function saveProperImageCache() {
try {
localStorage.setItem(IMAGE_CACHE_KEY, JSON.stringify(getProperImageCache()));
} catch (_) {
// Non-critical: images still load for the current page if storage is unavailable.
}
}
function wikiThumbnailUrl(title, size = 700) {
const params = new URLSearchParams({
action: 'query',
prop: 'pageimages',
format: 'json',
piprop: 'thumbnail',
pithumbsize: String(size),
redirects: '1',
origin: '*',
titles: title,
});
return `${IMAGE_API_ENDPOINT}?${params.toString()}`;
}
async function resolveProperImage(title) {
const cache = getProperImageCache();
if (Object.prototype.hasOwnProperty.call(cache, title)) return cache[title];
const response = await fetch(wikiThumbnailUrl(title));
if (!response.ok) throw new Error(`Image lookup failed for ${title}`);
const data = await response.json();
const page = Object.values(data.query?.pages || {})[0];
const source = page?.thumbnail?.source || '';
cache[title] = source;
saveProperImageCache();
return source;
}
function hydrateProperImages(root = document) {
const holders = [...root.querySelectorAll('.proper-image[data-image-title]')];
holders.forEach(async (holder) => {
const img = holder.querySelector('.proper-image__img');
if (!img || holder.dataset.loaded) return;
holder.dataset.loaded = 'pending';
try {
const source = await resolveProperImage(holder.dataset.imageTitle);
if (!source) throw new Error('No thumbnail returned');
img.addEventListener('load', () => {
holder.classList.add('is-loaded');
img.hidden = false;
}, { once: true });
img.src = source;
holder.dataset.loaded = 'true';
} catch (_) {
holder.dataset.loaded = 'fallback';
}
});
}