6 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
Claude
ee140dbe4d Add Uta and her Uta Uta no Mi
Adds the One Piece Film: Red character Uta and her Song-Song devil
fruit, cross-linked the same way as every other character/fruit pair.
Both automatically appear in the existing search/filter grids and
detail pages since the site is fully data-driven.
2026-08-14 23:30:57 +00:00
13 changed files with 342 additions and 39 deletions

View File

@@ -34,6 +34,7 @@
<script src="js/characters-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/character.js"></script>
</body>

View File

@@ -367,18 +367,18 @@ p { margin: 0 0 1em; color: var(--text-muted); }
margin-bottom: 0.5rem;
}
.portrait-svg {
display: block;
width: 100%;
height: 100%;
}
.char-card__avatar {
width: 84px;
height: 84px;
margin: 0 auto 0.5rem;
border-radius: 50%;
display: flex;
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));
overflow: hidden;
border: 3px solid var(--ink);
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__icon {
font-size: 2rem;
margin-bottom: 0.6rem;
width: 56px;
height: 56px;
border-radius: 50%;
overflow: hidden;
margin-bottom: 0.7rem;
box-shadow: 0 0 0 1px var(--surface-border);
}
.fruit-card__inner h3 {
@@ -536,13 +540,7 @@ p { margin: 0 0 1em; color: var(--text-muted); }
height: 110px;
flex-shrink: 0;
border-radius: 50%;
display: flex;
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));
overflow: hidden;
border: 4px solid var(--bg);
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__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 {
font-size: 0.72rem;
@@ -708,13 +713,7 @@ p { margin: 0 0 1em; color: var(--text-muted); }
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
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));
overflow: hidden;
flex-shrink: 0;
}
@@ -735,8 +734,12 @@ p { margin: 0 0 1em; color: var(--text-muted); }
}
.fruit-detail-header__icon {
font-size: 4rem;
width: 100px;
height: 100px;
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; }
@@ -783,3 +786,35 @@ p { margin: 0 0 1em; color: var(--text-muted); }
.fruit-detail-header { padding: 1.25rem; }
.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/fruits-data.js"></script>
<script src="js/portraits.js"></script>
<script src="js/app.js"></script>
<script src="js/fruit.js"></script>
</body>

View File

@@ -60,6 +60,7 @@
<script src="js/characters-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/fruits.js"></script>
</body>

View File

@@ -62,6 +62,7 @@
<script src="js/characters-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/main.js"></script>
</body>

View File

@@ -36,11 +36,6 @@ function accentFor(entity) {
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) {
if (amount === null || amount === undefined) return 'Unknown';
return '฿' + amount.toLocaleString('en-US');
@@ -79,6 +74,16 @@ function statusBadgeClass(status) {
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) {
const accent = accentFor(c);
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)}">
<div class="char-card__poster">
${deadRibbon}
<span class="char-card__wanted">WANTED</span>
<div class="char-card__avatar">${initialFor(c.name)}</div>
<span class="char-card__wanted">${escapeHtml(characterPosterLabel(c))}</span>
<div class="char-card__avatar">${characterPortraitSVG(c)}</div>
</div>
<div class="char-card__body">
<h3>${escapeHtml(c.name)}</h3>
<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">
<span class="tag">${escapeHtml(c.affiliation)}</span>
${fruitTag}
@@ -112,7 +117,7 @@ function fruitCardHTML(f) {
<article class="fruit-card" data-cat="${escapeHtml(f.category)}">
<a href="${fruitLink(f.id)}" aria-label="View ${escapeHtml(f.name)}">
<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>
<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>

View File

@@ -35,14 +35,14 @@
root.innerHTML = `
<section class="profile-header" style="--accent:${accent}">
<div class="profile-avatar">${initialFor(character.name)}</div>
<div class="profile-avatar">${characterPortraitSVG(character)}</div>
<div>
<p class="profile-eyebrow">${escapeHtml(character.affiliation)} · ${escapeHtml(character.role)}</p>
<h1>${escapeHtml(character.name)}</h1>
<p class="profile-epithet">${character.epithet ? '"' + escapeHtml(character.epithet) + '"' : ''}</p>
<div class="profile-badges">
<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>` : ''}
</div>
</div>
@@ -52,7 +52,7 @@
<div class="profile-main">
${fruit ? `
<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>
<p class="fruit-callout__label">${escapeHtml(fruitRelation ? fruitRelation.status : 'Devil Fruit')}</p>
<h3>${escapeHtml(fruit.name)}</h3>
@@ -82,4 +82,6 @@
</aside>
</div>
`;
hydrateProperImages(root);
})();

View File

@@ -801,5 +801,13 @@ const CHARACTERS = [
devilFruit: null, firstAppearance: 'Chapter 655, "Kinemon of the Foxfire"',
abilities: ['Occasional forced dragon transformation (via SMILE-derived drug)', 'Rightful claim to Wano\'s shogunate'],
bio: 'The son of Kozuki Oden, thrust into leadership as Wano\'s new shogun after twenty years frozen in time by Toki\'s Devil Fruit. Momonosuke\'s reluctant, often terrified heroism during the raid on Onigashima, including flying into battle as a dragon, marked his growth into the heir his father hoped he would become.'
},
{
id: 'uta', name: 'Uta', epithet: "World's Greatest Diva",
affiliation: 'Independent (raised by the Red Hair Pirates)', role: 'Singer', origin: "Raised aboard Shanks' ship, later Elbaf",
age: 18, height: '156 cm', birthday: 'March 8', bounty: null, status: 'Deceased',
devilFruit: 'uta-uta-no-mi', firstAppearance: 'One Piece Film: Red (2022)',
abilities: ['Uta Uta no Mi (trapping listeners inside Uta no Sekai)', 'World-renowned singing voice', 'Advanced physical combat (implied Haki)'],
bio: "The secret daughter of \"Red-Haired\" Shanks and Luffy's closest childhood friend, hidden away and raised on Elbaf after Shanks told her he had died, so she would never be tempted to follow him into a pirate's life. Uta grew up to become the most famous singer in the world, performing behind a hidden identity. At her debut concert on the Island of Elegia, grief and a desire to end all suffering drove her to use her Devil Fruit to try to trap humanity inside a permanent dream world, a plan called \"New Genesis\" that only Luffy — and her father — could stop. She died in Shanks' arms soon after, her heart unable to bear the strain the power had placed on it since childhood. (Appears in One Piece Film: Red, a movie storyline outside the main manga/anime continuity.)"
}
];

View File

@@ -25,7 +25,7 @@
if (!c) return '';
return `
<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 class="user-chip__name">${escapeHtml(c.name)}</div>
<div class="user-chip__status">${escapeHtml(u.status)}</div>
@@ -36,7 +36,7 @@
root.innerHTML = `
<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>
<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>
@@ -79,4 +79,6 @@
</aside>
</div>
`;
hydrateProperImages(root);
})();

View File

@@ -952,5 +952,22 @@ const FRUITS = [
strengths: "Combines huge physical scale with air-splitting shockwave attacks, ideal for large-scale battlefield control.",
weaknesses: "The form's size can be unwieldy in enclosed spaces or against fast, evasive opponents.",
users: [{ id: 'sengoku', status: 'Current user' }]
},
{
id: 'uta-uta-no-mi', name: 'Uta Uta no Mi', englishName: 'Song-Song Fruit',
category: 'Paramecia', subtype: null, awakened: false, icon: '🎤',
meaning: '"Uta" — song',
tagline: "Lets the user pull anyone who hears them sing into a shared dream world of their own making.",
description: "A Paramecia fruit that turns the user's singing voice into a gateway: anyone who hears the song can be drawn, body and mind, into an internal dream world the user controls — called Uta no Sekai — with the line between dream and reality blurring the longer a victim stays trapped inside.",
history: "The fruit's history before Uta is unrecorded. She ate it as a child growing up in secret aboard the Red Hair Pirates' ship, and an early, uncontrolled use of its power left her with a heart weakened for the rest of her life, a risk Shanks chose to shield her from rather than explain.\n\nYears later, performing as the world's most famous singer under a hidden identity, Uta used the fruit at her debut concert on the Island of Elegia to try to pull all of humanity permanently into Uta no Sekai as part of a plan she called \"New Genesis,\" believing a shared dream was kinder than a world full of pain. Monkey D. Luffy fought his way through the dream world to reach her and broke the technique, freeing everyone trapped inside — though the toll on Uta's own heart proved fatal soon after. (Introduced in One Piece Film: Red, a movie storyline outside the main manga/anime continuity.)",
powers: [
"Pulling anyone who hears the user sing into a shared dream world (Uta no Sekai)",
"Full sensory and bodily control over victims while they remain inside the dream",
"The dream world gradually merges with reality the longer victims stay trapped",
"Sufficiently powerful real-world attacks reaching a victim can forcibly eject them from the dream"
],
strengths: "Can incapacitate enormous crowds simultaneously with nothing but a song, requiring no direct contact.",
weaknesses: "Physically taxing on the user's own body and heart; victims can potentially be shocked back to reality by a strong enough attack.",
users: [{ id: 'uta', status: 'Current user (deceased)' }]
}
];

View File

@@ -33,6 +33,7 @@
renderCardGrid(grid, results, fruitCardHTML, 'No Devil Fruits match your search.');
resultCount.textContent = `${results.length} of ${FRUITS.length} Devil Fruits`;
hydrateProperImages(grid);
}
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.');
resultCount.textContent = `${results.length} of ${CHARACTERS.length} characters`;
hydrateProperImages(grid);
}
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';
}
});
}