5 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
47d40c0cc6 Merge remote-tracking branch 'origin/claude/one-piece-wiki-hjbn4q' into codex/add-images-for-characters-and-devil-fruits-q49vz4
# Conflicts:
#	css/styles.css

Co-authored-by: Cametendo <119795084+Cametendo@users.noreply.github.com>
2026-08-15 00:06:58 +00:00
Cametendo
c76119b923 Refine portrait frames and non-bounty cards 2026-08-15 02:04:46 +02:00
Cametendo
266925ff4a Merge pull request #5 from Cametendo/codex/add-images-for-characters-and-devil-fruits
Load proper Fandom thumbnails for character and fruit portraits
2026-08-15 01:59:04 +02:00
Cametendo
29a2243690 Add proper wiki images for portraits 2026-08-15 01:58:40 +02:00
Claude
c645ee09d2 Add original generated portrait/icon artwork for every entry
Adds js/portraits.js: procedurally generated, original SVG artwork for
all 101 characters (a role-themed line-art medallion — sword for
swordsmen, crown for royalty, compass for navigators, etc., derived
from each character's role/epithet/affiliation) and all 58 Devil
Fruits (a stylized swirled-fruit icon colored by category with the
fruit's symbolic emoji at center).

Official One Piece artwork is copyrighted (Oda/Shueisha/Toei), so
rather than scraping and re-hosting character art from the web, every
image here is drawn from scratch with basic SVG primitives — no
external assets, no copyright risk, fully offline-capable, and crisp
at any size since it's vector.

Wires the new generators into every card and detail view: character
cards/profile header, Devil Fruit cards/detail header, the Devil-Fruit
callout on character pages, and user chips on Devil Fruit pages.
Removes the now-unused plain-letter initialFor() avatar helper.
2026-08-14 23:47:39 +00:00
11 changed files with 317 additions and 39 deletions

View File

@@ -34,6 +34,7 @@
<script src="js/characters-data.js"></script> <script src="js/characters-data.js"></script>
<script src="js/fruits-data.js"></script> <script src="js/fruits-data.js"></script>
<script src="js/portraits.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
<script src="js/character.js"></script> <script src="js/character.js"></script>
</body> </body>

View File

@@ -367,18 +367,18 @@ p { margin: 0 0 1em; color: var(--text-muted); }
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.portrait-svg {
display: block;
width: 100%;
height: 100%;
}
.char-card__avatar { .char-card__avatar {
width: 84px; width: 84px;
height: 84px; height: 84px;
margin: 0 auto 0.5rem; margin: 0 auto 0.5rem;
border-radius: 50%; border-radius: 50%;
display: flex; overflow: hidden;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 2.1rem;
color: #fff;
background: linear-gradient(145deg, var(--accent, var(--gold-deep)), color-mix(in srgb, var(--accent, var(--gold-deep)) 60%, black));
border: 3px solid var(--ink); border: 3px solid var(--ink);
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.25); box-shadow: inset 0 0 0 2px rgba(255,255,255,0.25);
} }
@@ -464,8 +464,12 @@ p { margin: 0 0 1em; color: var(--text-muted); }
.fruit-card__inner { padding: 1.1rem 1.15rem; } .fruit-card__inner { padding: 1.1rem 1.15rem; }
.fruit-card__icon { .fruit-card__icon {
font-size: 2rem; width: 56px;
margin-bottom: 0.6rem; height: 56px;
border-radius: 50%;
overflow: hidden;
margin-bottom: 0.7rem;
box-shadow: 0 0 0 1px var(--surface-border);
} }
.fruit-card__inner h3 { .fruit-card__inner h3 {
@@ -536,13 +540,7 @@ p { margin: 0 0 1em; color: var(--text-muted); }
height: 110px; height: 110px;
flex-shrink: 0; flex-shrink: 0;
border-radius: 50%; border-radius: 50%;
display: flex; overflow: hidden;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 2.6rem;
color: #fff;
background: linear-gradient(145deg, var(--accent, var(--gold)), color-mix(in srgb, var(--accent, var(--gold)) 55%, black));
border: 4px solid var(--bg); border: 4px solid var(--bg);
box-shadow: 0 0 0 3px var(--accent, var(--gold)), var(--shadow-sm); box-shadow: 0 0 0 3px var(--accent, var(--gold)), var(--shadow-sm);
} }
@@ -625,7 +623,14 @@ p { margin: 0 0 1em; color: var(--text-muted); }
.fruit-callout:hover { transform: translateX(2px); border-color: var(--gold); } .fruit-callout:hover { transform: translateX(2px); border-color: var(--gold); }
.fruit-callout__icon { font-size: 2.4rem; flex-shrink: 0; } .fruit-callout__icon {
width: 68px;
height: 68px;
flex-shrink: 0;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 0 0 1px var(--surface-border-strong);
}
.fruit-callout__label { .fruit-callout__label {
font-size: 0.72rem; font-size: 0.72rem;
@@ -708,13 +713,7 @@ p { margin: 0 0 1em; color: var(--text-muted); }
width: 38px; width: 38px;
height: 38px; height: 38px;
border-radius: 50%; border-radius: 50%;
display: flex; overflow: hidden;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 1.1rem;
color: #fff;
background: linear-gradient(145deg, var(--accent, var(--gold)), color-mix(in srgb, var(--accent, var(--gold)) 55%, black));
flex-shrink: 0; flex-shrink: 0;
} }
@@ -735,8 +734,12 @@ p { margin: 0 0 1em; color: var(--text-muted); }
} }
.fruit-detail-header__icon { .fruit-detail-header__icon {
font-size: 4rem; width: 100px;
height: 100px;
flex-shrink: 0; flex-shrink: 0;
border-radius: 50%;
overflow: hidden;
box-shadow: 0 0 0 3px var(--cat-color, var(--gold)), var(--shadow-sm);
} }
.fruit-detail-header h1 { margin-bottom: 0.2rem; } .fruit-detail-header h1 { margin-bottom: 0.2rem; }
@@ -783,3 +786,35 @@ p { margin: 0 0 1em; color: var(--text-muted); }
.fruit-detail-header { padding: 1.25rem; } .fruit-detail-header { padding: 1.25rem; }
.filter-meta { margin-left: 0; width: 100%; } .filter-meta { margin-left: 0; width: 100%; }
} }
/* ---------- Proper wiki images ---------- */
.proper-image {
position: relative;
display: block;
width: 100%;
height: 100%;
background: #0b1220;
}
.proper-image__fallback,
.proper-image__img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.proper-image__img {
object-fit: cover;
object-position: top center;
z-index: 1;
}
.proper-image--fruit .proper-image__img {
object-fit: cover;
object-position: center;
padding: 0;
background: #0b1220;
}
.proper-image.is-loaded .proper-image__fallback { opacity: 0; }

View File

@@ -34,6 +34,7 @@
<script src="js/characters-data.js"></script> <script src="js/characters-data.js"></script>
<script src="js/fruits-data.js"></script> <script src="js/fruits-data.js"></script>
<script src="js/portraits.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
<script src="js/fruit.js"></script> <script src="js/fruit.js"></script>
</body> </body>

View File

@@ -60,6 +60,7 @@
<script src="js/characters-data.js"></script> <script src="js/characters-data.js"></script>
<script src="js/fruits-data.js"></script> <script src="js/fruits-data.js"></script>
<script src="js/portraits.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
<script src="js/fruits.js"></script> <script src="js/fruits.js"></script>
</body> </body>

View File

@@ -62,6 +62,7 @@
<script src="js/characters-data.js"></script> <script src="js/characters-data.js"></script>
<script src="js/fruits-data.js"></script> <script src="js/fruits-data.js"></script>
<script src="js/portraits.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
<script src="js/main.js"></script> <script src="js/main.js"></script>
</body> </body>

View File

@@ -36,11 +36,6 @@ function accentFor(entity) {
return `hsl(${hashHue(entity.name || key)}, 58%, 48%)`; return `hsl(${hashHue(entity.name || key)}, 58%, 48%)`;
} }
function initialFor(name) {
const match = (name || '?').match(/[A-Za-z]/);
return match ? match[0].toUpperCase() : '?';
}
function formatBounty(amount) { function formatBounty(amount) {
if (amount === null || amount === undefined) return 'Unknown'; if (amount === null || amount === undefined) return 'Unknown';
return '฿' + amount.toLocaleString('en-US'); return '฿' + amount.toLocaleString('en-US');
@@ -79,6 +74,16 @@ function statusBadgeClass(status) {
return 'badge--unknown'; return 'badge--unknown';
} }
function characterPosterLabel(c) {
if (c.bounty !== null && c.bounty !== undefined) return 'WANTED';
if (/marine/i.test(c.affiliation || '')) return 'MARINE';
return (c.affiliation || c.role || 'UNKNOWN').split(/[/(]/)[0].trim().toUpperCase();
}
function characterBountyOrRank(c) {
return c.bounty !== null && c.bounty !== undefined ? formatBounty(c.bounty) : c.role || 'Rank unknown';
}
function characterCardHTML(c) { function characterCardHTML(c) {
const accent = accentFor(c); const accent = accentFor(c);
const deadRibbon = /deceased/i.test(c.status || '') ? '<span class="char-card__status">Deceased</span>' : ''; const deadRibbon = /deceased/i.test(c.status || '') ? '<span class="char-card__status">Deceased</span>' : '';
@@ -88,13 +93,13 @@ function characterCardHTML(c) {
<a href="${characterLink(c.id)}" aria-label="View ${escapeHtml(c.name)}"> <a href="${characterLink(c.id)}" aria-label="View ${escapeHtml(c.name)}">
<div class="char-card__poster"> <div class="char-card__poster">
${deadRibbon} ${deadRibbon}
<span class="char-card__wanted">WANTED</span> <span class="char-card__wanted">${escapeHtml(characterPosterLabel(c))}</span>
<div class="char-card__avatar">${initialFor(c.name)}</div> <div class="char-card__avatar">${characterPortraitSVG(c)}</div>
</div> </div>
<div class="char-card__body"> <div class="char-card__body">
<h3>${escapeHtml(c.name)}</h3> <h3>${escapeHtml(c.name)}</h3>
<p class="char-card__epithet">${c.epithet ? '"' + escapeHtml(c.epithet) + '"' : ' '}</p> <p class="char-card__epithet">${c.epithet ? '"' + escapeHtml(c.epithet) + '"' : ' '}</p>
<p class="char-card__bounty">${c.bounty ? formatBounty(c.bounty) : 'Bounty Unknown'}</p> <p class="char-card__bounty">${escapeHtml(characterBountyOrRank(c))}</p>
<div class="char-card__tags"> <div class="char-card__tags">
<span class="tag">${escapeHtml(c.affiliation)}</span> <span class="tag">${escapeHtml(c.affiliation)}</span>
${fruitTag} ${fruitTag}
@@ -112,7 +117,7 @@ function fruitCardHTML(f) {
<article class="fruit-card" data-cat="${escapeHtml(f.category)}"> <article class="fruit-card" data-cat="${escapeHtml(f.category)}">
<a href="${fruitLink(f.id)}" aria-label="View ${escapeHtml(f.name)}"> <a href="${fruitLink(f.id)}" aria-label="View ${escapeHtml(f.name)}">
<div class="fruit-card__inner"> <div class="fruit-card__inner">
<div class="fruit-card__icon">${f.icon}</div> <div class="fruit-card__icon">${fruitPortraitSVG(f)}</div>
<h3>${escapeHtml(f.name)}</h3> <h3>${escapeHtml(f.name)}</h3>
<span class="fruit-card__type">${escapeHtml(f.category)}${f.subtype ? ' · ' + escapeHtml(f.subtype.split(' —')[0].split(' (')[0]) : ''}</span> <span class="fruit-card__type">${escapeHtml(f.category)}${f.subtype ? ' · ' + escapeHtml(f.subtype.split(' —')[0].split(' (')[0]) : ''}</span>
<p class="fruit-card__tagline">${escapeHtml(f.tagline)}</p> <p class="fruit-card__tagline">${escapeHtml(f.tagline)}</p>

View File

@@ -35,14 +35,14 @@
root.innerHTML = ` root.innerHTML = `
<section class="profile-header" style="--accent:${accent}"> <section class="profile-header" style="--accent:${accent}">
<div class="profile-avatar">${initialFor(character.name)}</div> <div class="profile-avatar">${characterPortraitSVG(character)}</div>
<div> <div>
<p class="profile-eyebrow">${escapeHtml(character.affiliation)} · ${escapeHtml(character.role)}</p> <p class="profile-eyebrow">${escapeHtml(character.affiliation)} · ${escapeHtml(character.role)}</p>
<h1>${escapeHtml(character.name)}</h1> <h1>${escapeHtml(character.name)}</h1>
<p class="profile-epithet">${character.epithet ? '"' + escapeHtml(character.epithet) + '"' : ''}</p> <p class="profile-epithet">${character.epithet ? '"' + escapeHtml(character.epithet) + '"' : ''}</p>
<div class="profile-badges"> <div class="profile-badges">
<span class="badge ${statusBadgeClass(character.status)}">${escapeHtml(character.status || 'Unknown')}</span> <span class="badge ${statusBadgeClass(character.status)}">${escapeHtml(character.status || 'Unknown')}</span>
<span class="badge badge--bounty">${character.bounty ? formatBounty(character.bounty) : 'Bounty Unknown'}</span> <span class="badge badge--bounty">${escapeHtml(characterBountyOrRank(character))}</span>
${fruit ? `<span class="badge" data-cat="${escapeHtml(fruit.category)}" style="color:var(--cat-color)">${escapeHtml(fruit.category)} User</span>` : ''} ${fruit ? `<span class="badge" data-cat="${escapeHtml(fruit.category)}" style="color:var(--cat-color)">${escapeHtml(fruit.category)} User</span>` : ''}
</div> </div>
</div> </div>
@@ -52,7 +52,7 @@
<div class="profile-main"> <div class="profile-main">
${fruit ? ` ${fruit ? `
<a class="fruit-callout" href="${fruitLink(fruit.id)}"> <a class="fruit-callout" href="${fruitLink(fruit.id)}">
<div class="fruit-callout__icon">${fruit.icon}</div> <div class="fruit-callout__icon">${fruitPortraitSVG(fruit)}</div>
<div> <div>
<p class="fruit-callout__label">${escapeHtml(fruitRelation ? fruitRelation.status : 'Devil Fruit')}</p> <p class="fruit-callout__label">${escapeHtml(fruitRelation ? fruitRelation.status : 'Devil Fruit')}</p>
<h3>${escapeHtml(fruit.name)}</h3> <h3>${escapeHtml(fruit.name)}</h3>
@@ -82,4 +82,6 @@
</aside> </aside>
</div> </div>
`; `;
hydrateProperImages(root);
})(); })();

View File

@@ -25,7 +25,7 @@
if (!c) return ''; if (!c) return '';
return ` return `
<a class="user-chip" href="${characterLink(c.id)}" style="--accent:${accentFor(c)}"> <a class="user-chip" href="${characterLink(c.id)}" style="--accent:${accentFor(c)}">
<div class="user-chip__avatar">${initialFor(c.name)}</div> <div class="user-chip__avatar">${characterPortraitSVG(c)}</div>
<div> <div>
<div class="user-chip__name">${escapeHtml(c.name)}</div> <div class="user-chip__name">${escapeHtml(c.name)}</div>
<div class="user-chip__status">${escapeHtml(u.status)}</div> <div class="user-chip__status">${escapeHtml(u.status)}</div>
@@ -36,7 +36,7 @@
root.innerHTML = ` root.innerHTML = `
<section class="fruit-detail-header" data-cat="${escapeHtml(fruit.category)}"> <section class="fruit-detail-header" data-cat="${escapeHtml(fruit.category)}">
<div class="fruit-detail-header__icon">${fruit.icon}</div> <div class="fruit-detail-header__icon">${fruitPortraitSVG(fruit)}</div>
<div> <div>
<p class="profile-eyebrow" style="color:var(--cat-color)">${escapeHtml(fruit.category)}${fruit.subtype ? ' · ' + escapeHtml(fruit.subtype) : ''}${fruit.awakened ? ' · Awakened' : ''}</p> <p class="profile-eyebrow" style="color:var(--cat-color)">${escapeHtml(fruit.category)}${fruit.subtype ? ' · ' + escapeHtml(fruit.subtype) : ''}${fruit.awakened ? ' · Awakened' : ''}</p>
<h1>${escapeHtml(fruit.name)}</h1> <h1>${escapeHtml(fruit.name)}</h1>
@@ -79,4 +79,6 @@
</aside> </aside>
</div> </div>
`; `;
hydrateProperImages(root);
})(); })();

View File

@@ -33,6 +33,7 @@
renderCardGrid(grid, results, fruitCardHTML, 'No Devil Fruits match your search.'); renderCardGrid(grid, results, fruitCardHTML, 'No Devil Fruits match your search.');
resultCount.textContent = `${results.length} of ${FRUITS.length} Devil Fruits`; resultCount.textContent = `${results.length} of ${FRUITS.length} Devil Fruits`;
hydrateProperImages(grid);
} }
chipRow.addEventListener('click', (e) => { chipRow.addEventListener('click', (e) => {

View File

@@ -35,6 +35,7 @@
renderCardGrid(grid, results, characterCardHTML, 'No characters match your search. Try a different name or affiliation.'); renderCardGrid(grid, results, characterCardHTML, 'No characters match your search. Try a different name or affiliation.');
resultCount.textContent = `${results.length} of ${CHARACTERS.length} characters`; resultCount.textContent = `${results.length} of ${CHARACTERS.length} characters`;
hydrateProperImages(grid);
} }
searchInput.addEventListener('input', applyFilters); searchInput.addEventListener('input', applyFilters);

228
js/portraits.js Normal file
View File

@@ -0,0 +1,228 @@
/* 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 += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke-width="${strokeWidth}"/>`;
}
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: '<g transform="rotate(45 32 32)"><line x1="32" y1="10" x2="32" y2="42"/><line x1="24" y1="18" x2="40" y2="18"/><line x1="32" y1="42" x2="32" y2="50"/></g><g transform="rotate(-45 32 32)"><line x1="32" y1="10" x2="32" y2="42"/><line x1="24" y1="18" x2="40" y2="18"/><line x1="32" y1="42" x2="32" y2="50"/></g>',
compass: `<circle cx="32" cy="32" r="18"/>${radialLines(32, 32, 15, 18, 12, 2)}<polygon points="32,21 37,32 32,43 27,32" fill="currentColor" stroke="none"/>`,
target: '<circle cx="32" cy="32" r="18"/><circle cx="32" cy="32" r="11"/><circle cx="32" cy="32" r="4" fill="currentColor" stroke="none"/>',
cross: '<line x1="32" y1="14" x2="32" y2="50"/><line x1="14" y1="32" x2="50" y2="32"/>',
scroll: '<rect x="16" y="20" width="32" height="24" rx="2"/><circle cx="16" cy="20" r="4"/><circle cx="16" cy="44" r="4"/><circle cx="48" cy="20" r="4"/><circle cx="48" cy="44" r="4"/><line x1="22" y1="28" x2="42" y2="28"/><line x1="22" y1="36" x2="42" y2="36"/>',
gear: `<circle cx="32" cy="32" r="9"/>${radialLines(32, 32, 12, 18, 8, 3)}`,
note: '<circle cx="23" cy="46" r="6"/><circle cx="43" cy="42" r="6"/><line x1="29" y1="46" x2="29" y2="16"/><line x1="49" y1="42" x2="49" y2="14"/><line x1="29" y1="16" x2="49" y2="14"/><line x1="29" y1="24" x2="49" y2="22"/>',
chefhat: '<path d="M20 44 h24 v6 h-24 z"/><path d="M22 44 C18 30 22 18 32 18 C34 12 44 12 46 18 C54 20 54 32 46 36 C46 40 46 42 44 44 h-22 C20 42 20 44 22 44 Z"/>',
flask: '<line x1="26" y1="12" x2="26" y2="26"/><line x1="38" y1="12" x2="38" y2="26"/><line x1="24" y1="12" x2="40" y2="12"/><path d="M26 26 L16 48 a4 4 0 0 0 4 6 h24 a4 4 0 0 0 4 -6 L38 26 Z"/><line x1="20" y1="40" x2="44" y2="40"/>',
anchor: '<circle cx="32" cy="16" r="5"/><line x1="32" y1="21" x2="32" y2="50"/><line x1="18" y1="30" x2="46" y2="30"/><path d="M18 38 a14 14 0 0 0 14 12"/><path d="M46 38 a14 14 0 0 1 -14 12"/>',
crown: '<polygon points="16,44 16,26 24,34 32,20 40,34 48,26 48,44"/><line x1="16" y1="44" x2="48" y2="44"/>',
tiara: '<path d="M16 40 Q32 16 48 40"/><circle cx="32" cy="22" r="4" fill="currentColor" stroke="none"/><line x1="16" y1="40" x2="48" y2="40"/>',
flag: '<line x1="20" y1="12" x2="20" y2="52"/><path d="M20 14 h26 l-8 9 8 9 h-26 z"/>',
dagger: '<line x1="32" y1="12" x2="32" y2="38"/><line x1="24" y1="20" x2="40" y2="20"/><polygon points="32,38 26,46 38,46"/>',
skull: '<circle cx="32" cy="28" r="16"/><circle cx="25" cy="26" r="3" fill="currentColor" stroke="none"/><circle cx="39" cy="26" r="3" fill="currentColor" stroke="none"/><path d="M24 38 h16 M26 44 h4 M34 44 h4"/>',
paw: '<circle cx="32" cy="38" r="10"/><circle cx="20" cy="24" r="5"/><circle cx="32" cy="18" r="5"/><circle cx="44" cy="24" r="5"/>',
wing: '<path d="M14 40 C22 24 30 18 34 30 C38 18 46 24 50 40 C40 34 40 40 34 34 C28 40 24 34 14 40 Z"/>',
star: `<polygon points="${starPoints(32, 32, 18, 8, 5)}"/>`,
};
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 `
<span class="proper-image proper-image--${kind}" data-image-title="${escapeHtml(title)}" data-image-kind="${kind}">
<span class="proper-image__fallback">${fallback}</span>
<img class="proper-image__img" alt="${escapeHtml(label)}" loading="lazy" referrerpolicy="no-referrer" hidden>
</span>
`;
}
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 `
<svg viewBox="0 0 64 64" role="img" aria-label="${escapeHtml(c.name)} portrait" class="portrait-svg">
<defs>
<linearGradient id="g-${uid}" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="${accent}"/>
<stop offset="100%" stop-color="hsl(${hue2}, 55%, 26%)"/>
</linearGradient>
<pattern id="p-${uid}" width="9" height="9" patternTransform="rotate(${rotation})" patternUnits="userSpaceOnUse">
<line x1="0" y1="0" x2="0" y2="9" stroke="rgba(255,255,255,0.09)" stroke-width="2"/>
</pattern>
</defs>
<circle cx="32" cy="32" r="32" fill="url(#g-${uid})"/>
<circle cx="32" cy="32" r="32" fill="url(#p-${uid})"/>
<circle cx="32" cy="32" r="30.5" fill="none" stroke="rgba(255,255,255,0.18)" stroke-width="1"/>
<g fill="none" stroke="#fdf6e3" stroke-width="3.1" stroke-linecap="round" stroke-linejoin="round" opacity="0.95">${icon}</g>
${fruit ? `<circle cx="49" cy="49" r="11.5" fill="#0b1220" stroke="#fdf6e3" stroke-width="1.5"/><text x="49" y="53.5" font-size="12" text-anchor="middle">${fruit.icon}</text>` : ''}
</svg>
`;
}
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 `
<svg viewBox="0 0 64 64" role="img" aria-label="${escapeHtml(f.name)} icon" class="portrait-svg">
<defs>
<radialGradient id="fg-${uid}" cx="35%" cy="28%" r="80%">
<stop offset="0%" stop-color="${color}" stop-opacity="0.9"/>
<stop offset="100%" stop-color="${color}" stop-opacity="0.28"/>
</radialGradient>
</defs>
<circle cx="32" cy="34" r="32" fill="#0b1220"/>
<path d="M32 14 C21 14 13 23 13 34 C13 47 21 54 32 54 C43 54 51 47 51 34 C51 23 43 14 32 14 Z"
fill="url(#fg-${uid})" stroke="${glow ? '#e3b04b' : color}" stroke-width="2.2"/>
<path d="M32 14 C30 9 31 5 36 3" fill="none" stroke="#7a9b57" stroke-width="3" stroke-linecap="round"/>
<path d="M20 27 C25 23 39 23 44 27" fill="none" stroke="rgba(255,255,255,0.32)" stroke-width="2" stroke-linecap="round"/>
<path d="M22 40 C27 44 37 44 42 40" fill="none" stroke="rgba(0,0,0,0.18)" stroke-width="2" stroke-linecap="round"/>
<text x="32" y="41" font-size="19" text-anchor="middle">${f.icon}</text>
</svg>
`;
}
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';
}
});
}