diff --git a/script.js b/script.js
index 1afa77c..c5844d0 100644
--- a/script.js
+++ b/script.js
@@ -1429,15 +1429,24 @@
async function imageUrlToDataUrl(src) {
if (!src || src.startsWith('data:')) return src;
if (DATA_URL_CACHE.has(src)) return DATA_URL_CACHE.get(src);
+ const abs = (() => { try { return new URL(src, window.location.href).href; } catch { return src; } })();
+ const urlObj = (() => { try { return new URL(abs); } catch { return null; } })();
+ const isFile = urlObj?.protocol === 'file:';
+ if (isFile) { // file:// cannot be safely read for data URLs in-browser
+ DATA_URL_CACHE.set(src, null);
+ return null;
+ }
+
const cachedImg = IMG_CACHE.get(src);
const cachedUrl = imageToDataUrl(cachedImg);
if (cachedUrl) {
DATA_URL_CACHE.set(src, cachedUrl);
return cachedUrl;
}
- const abs = (() => { try { return new URL(src, window.location.href).href; } catch { return src; } })();
+
let dataUrl = null;
try {
+ if (isFile) throw new Error('Skip fetch for file://');
const resp = await fetch(abs);
if (!resp.ok) throw new Error(`Status ${resp.status}`);
dataUrl = await blobToDataUrl(await resp.blob());
@@ -1446,7 +1455,7 @@
// Fallback: draw to a canvas to capture even when fetch is blocked (e.g., file://)
dataUrl = await new Promise(resolve => {
const img = new Image();
- img.crossOrigin = 'anonymous';
+ if (!isFile) img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const c = document.createElement('canvas');
@@ -1491,7 +1500,7 @@
await Promise.all(uniqueImageUrls.map(async (url) => dataUrlMap.set(url, await imageUrlToDataUrl(url))));
const bounds = balloonsBounds();
- const pad = 20;
+ const pad = 50; // extra room to avoid clipping drop-shadows
const width = bounds.w + pad * 2;
const height = bounds.h + pad * 2;
const vb = [bounds.minX - pad, bounds.minY - pad, width, height].join(' ');
@@ -1499,12 +1508,33 @@
let defs = '';
let elements = '';
const patterns = new Map();
+ const shadowFilters = new Map();
+
+ const ensureShadowFilter = (dx, dy, blurPx, alpha) => {
+ const key = `${dx}|${dy}|${blurPx}|${alpha}`;
+ if (!shadowFilters.has(key)) {
+ const id = `shadow-${shadowFilters.size}`;
+ const stdDev = Math.max(0.01, blurPx * 0.5);
+ const clampedAlpha = clamp01(alpha);
+ const flood = ``;
+ const blur = ``;
+ const offset = ``;
+ const composite = ``;
+ const merge = ``;
+ defs += `${flood}${blur}${offset}${composite}${merge}`;
+ shadowFilters.set(key, id);
+ }
+ return shadowFilters.get(key);
+ };
+ const shineShadowId = ensureShadowFilter(0, 0, 3, 0.1);
balloons.forEach(b => {
+ const meta = FLAT_COLORS[b.colorIdx] || {};
let fill = b.color;
if (b.image) {
const patternKey = `${b.colorIdx}|${b.image}`;
- if (!patterns.has(patternKey)) {
+ const imageHref = dataUrlMap.get(b.image);
+ if (!patterns.has(patternKey) && imageHref) {
const patternId = `p${patterns.size}`;
patterns.set(patternKey, patternId);
const meta = FLAT_COLORS[b.colorIdx] || {};
@@ -1514,15 +1544,29 @@
const imgW = zoom, imgH = zoom;
const imgX = 0.5 - (fx * zoom);
const imgY = 0.5 - (fy * zoom);
- const imageHref = dataUrlMap.get(b.image) || b.image;
defs += `
`;
}
- fill = `url(#${patterns.get(patternKey)})`;
+ if (patterns.has(patternKey)) fill = `url(#${patterns.get(patternKey)})`;
+ }
+ let filterAttr = '';
+ if (b.image) {
+ const lum = luminance(meta.hex || b.color);
+ if (lum > 0.6) {
+ const strength = clamp01((lum - 0.6) / 0.4);
+ const blur = 4 + 4 * strength;
+ const offsetY = 1 + 2 * strength;
+ const alpha = 0.05 + 0.07 * strength;
+ const id = ensureShadowFilter(0, offsetY, blur, alpha);
+ filterAttr = ` filter="url(#${id})"`;
+ }
+ } else {
+ const id = ensureShadowFilter(0, 0, 10, 0.2);
+ filterAttr = ` filter="url(#${id})"`;
}
const strokeAttr = isBorderEnabled ? ` stroke="#111827" stroke-width="0.5"` : ` stroke="none" stroke-width="0"`;
- elements += ``;
+ elements += ``;
if (isShineEnabled) {
const sx = b.x - b.radius * SHINE_OFFSET;
@@ -1531,7 +1575,8 @@
const ry = b.radius * SHINE_RY;
const { fill: shineFill, stroke: shineStroke } = shineStyle(b.color);
const stroke = shineStroke ? ` stroke="${shineStroke}" stroke-width="1"` : '';
- elements += ``;
+ const shineFilter = shineShadowId ? ` filter="url(#${shineShadowId})"` : '';
+ elements += ``;
}
});