chore: initialize insar management system v2
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* 地图导出工具函数
|
||||
* 纯函数,无 React 依赖
|
||||
*/
|
||||
|
||||
/* ---------- D-InSAR 伪彩色色表 (from qgis_color.txt) ---------- */
|
||||
|
||||
const DINSAR_COLORMAP = [
|
||||
{ value: -0.10, r: 255, g: 20, b: 0 },
|
||||
{ value: -0.08, r: 255, g: 20, b: 0 },
|
||||
{ value: -0.06, r: 240, g: 137, b: 0 },
|
||||
{ value: -0.02, r: 232, g: 240, b: 70 },
|
||||
{ value: 0.02, r: 24, g: 255, b: 0 },
|
||||
{ value: 0.06, r: 101, g: 185, b: 241 },
|
||||
{ value: 0.08, r: 0, g: 104, b: 200 },
|
||||
{ value: 0.10, r: 0, g: 104, b: 200 },
|
||||
];
|
||||
|
||||
/* ---------- 图层检测 ---------- */
|
||||
|
||||
const LAYER_DEFS = [
|
||||
{
|
||||
id: 'radar_orbit',
|
||||
ref: 'activeLayersRef',
|
||||
detect: (layers) => {
|
||||
const vals = layers ? Object.values(layers) : [];
|
||||
const hasGreen = vals.some(l => l?.options?.color === '#48bb78');
|
||||
return hasGreen;
|
||||
},
|
||||
color: '#48bb78', type: 'polygon',
|
||||
label: { en: 'Radar Coverage (with orbit)', zh: '雷达覆盖(有精轨)' },
|
||||
},
|
||||
{
|
||||
id: 'radar_no_orbit',
|
||||
ref: 'activeLayersRef',
|
||||
detect: (layers) => {
|
||||
const vals = layers ? Object.values(layers) : [];
|
||||
return vals.some(l => l?.options?.color === '#f56565');
|
||||
},
|
||||
color: '#f56565', type: 'polygon',
|
||||
label: { en: 'Radar Coverage (no orbit)', zh: '雷达覆盖(无精轨)' },
|
||||
},
|
||||
{
|
||||
id: 'hazard',
|
||||
ref: 'hazardLayersGroupRef',
|
||||
detect: (group) => group && typeof group.getLayers === 'function' && group.getLayers().length > 0,
|
||||
color: '#e53e3e', type: 'circle',
|
||||
label: { en: 'Hazard Points', zh: '隐患点' },
|
||||
},
|
||||
{
|
||||
id: 'dinsar',
|
||||
ref: 'dinsarResultLayersRef',
|
||||
detect: (layers) => layers && Object.keys(layers).length > 0,
|
||||
color: '#ff6b35', type: 'colorbar',
|
||||
label: { en: 'D-InSAR Displacement (m)', zh: 'D-InSAR 形变量 (m)' },
|
||||
},
|
||||
{
|
||||
id: 'water_scene',
|
||||
ref: 'waterSceneLayersRef',
|
||||
detect: (layers) => layers && Object.keys(layers).length > 0,
|
||||
color: '#00e5cc', type: 'polygon', dash: true,
|
||||
label: { en: 'Water Scenes', zh: '水体场景' },
|
||||
},
|
||||
{
|
||||
id: 'radar_preview',
|
||||
ref: 'radarPreviewLayersRef',
|
||||
detect: (layers) => layers && Object.keys(layers).length > 0,
|
||||
color: '#6366f1', type: 'image',
|
||||
label: { en: 'Radar Preview', zh: '雷达预览' },
|
||||
},
|
||||
{
|
||||
id: 'pair_master',
|
||||
ref: 'pairLayersRef',
|
||||
detect: (layers) => layers && Object.keys(layers).length > 0,
|
||||
color: '#3498db', type: 'polygon',
|
||||
label: { en: 'Pair Master Image', zh: '配对主影像' },
|
||||
},
|
||||
{
|
||||
id: 'pair_slave',
|
||||
ref: 'pairLayersRef',
|
||||
detect: (layers) => layers && Object.keys(layers).length > 0,
|
||||
color: '#2ecc71', type: 'polygon',
|
||||
label: { en: 'Pair Slave Image', zh: '配对辅影像' },
|
||||
},
|
||||
{
|
||||
id: 'aoi',
|
||||
ref: 'aoeLayerRef',
|
||||
detect: (layer) => layer !== null && layer !== undefined,
|
||||
color: '#f6e05e', type: 'polygon', dash: true,
|
||||
label: { en: 'AOI Boundary', zh: 'AOI 边界' },
|
||||
},
|
||||
{
|
||||
id: 'flood_event',
|
||||
ref: 'floodEventLayersRef',
|
||||
detect: (layers) => layers && Object.keys(layers).length > 0,
|
||||
color: '#e74c3c', type: 'image',
|
||||
label: { en: 'Flood Events', zh: '洪涝事件' },
|
||||
},
|
||||
];
|
||||
|
||||
export function detectVisibleLayers(layerRefs, language = 'zh') {
|
||||
const en = language === 'en';
|
||||
const items = [];
|
||||
for (const def of LAYER_DEFS) {
|
||||
const refVal = layerRefs[def.ref];
|
||||
if (def.detect(refVal)) {
|
||||
items.push({
|
||||
id: def.id,
|
||||
type: def.type,
|
||||
color: def.color,
|
||||
dash: !!def.dash,
|
||||
label: en ? def.label.en : def.label.zh,
|
||||
});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/* ---------- 比例尺计算 ---------- */
|
||||
|
||||
const NICE_NUMBERS = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 20000, 50000, 100000];
|
||||
|
||||
export function calculateScaleBar(map, pixelWidth = 150) {
|
||||
if (!map) return { distanceM: 0, label: '', barWidthPx: 0 };
|
||||
const size = map.getSize();
|
||||
const left = map.containerPointToLatLng([size.x / 2 - pixelWidth / 2, size.y / 2]);
|
||||
const right = map.containerPointToLatLng([size.x / 2 + pixelWidth / 2, size.y / 2]);
|
||||
const realDist = left.distanceTo(right); // meters
|
||||
// find nearest nice number
|
||||
let best = NICE_NUMBERS[0];
|
||||
for (const n of NICE_NUMBERS) {
|
||||
if (n <= realDist) best = n;
|
||||
else break;
|
||||
}
|
||||
const barWidthPx = Math.round(pixelWidth * (best / realDist));
|
||||
const label = best >= 1000 ? `${best / 1000} km` : `${best} m`;
|
||||
return { distanceM: best, label, barWidthPx };
|
||||
}
|
||||
|
||||
/* ---------- 指北针 SVG ---------- */
|
||||
|
||||
export function northArrowSvg(size = 48) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 48 48">
|
||||
<polygon points="24,2 30,22 24,18 18,22" fill="#333" stroke="#333" stroke-width="0.5"/>
|
||||
<polygon points="24,18 30,22 24,46 18,22" fill="#ccc" stroke="#333" stroke-width="0.5"/>
|
||||
<text x="24" y="14" text-anchor="middle" font-size="11" font-weight="bold" fill="#fff" font-family="Arial">N</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* ---------- Canvas 合成 ---------- */
|
||||
|
||||
function drawLegendSymbol(ctx, type, color, dash, x, y, size) {
|
||||
ctx.save();
|
||||
if (type === 'circle') {
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + size / 2, y + size / 2, size / 2 - 1, 0, Math.PI * 2);
|
||||
ctx.fillStyle = color;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
} else if (type === 'image') {
|
||||
ctx.fillStyle = color + '44';
|
||||
ctx.fillRect(x, y, size, size);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(x, y, size, size);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, y + size); ctx.lineTo(x + size, y);
|
||||
ctx.moveTo(x + size * 0.5, y + size); ctx.lineTo(x + size, y + size * 0.5);
|
||||
ctx.moveTo(x, y + size * 0.5); ctx.lineTo(x + size * 0.5, y);
|
||||
ctx.stroke();
|
||||
} else {
|
||||
ctx.fillStyle = color + '33';
|
||||
ctx.fillRect(x, y, size, size);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = 1.5;
|
||||
if (dash) ctx.setLineDash([4, 3]);
|
||||
ctx.strokeRect(x, y, size, size);
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/** 绘制 D-InSAR 渐变色条 + 刻度 */
|
||||
function drawColorbar(ctx, x, y, barW, barH, S) {
|
||||
const cmap = DINSAR_COLORMAP;
|
||||
const minVal = cmap[0].value;
|
||||
const maxVal = cmap[cmap.length - 1].value;
|
||||
const range = maxVal - minVal;
|
||||
|
||||
// draw gradient bar (horizontal)
|
||||
for (let px = 0; px < barW; px++) {
|
||||
const t = px / barW;
|
||||
const val = minVal + t * range;
|
||||
// find surrounding stops
|
||||
let lo = cmap[0], hi = cmap[cmap.length - 1];
|
||||
for (let i = 0; i < cmap.length - 1; i++) {
|
||||
if (val >= cmap[i].value && val <= cmap[i + 1].value) {
|
||||
lo = cmap[i]; hi = cmap[i + 1]; break;
|
||||
}
|
||||
}
|
||||
const segRange = hi.value - lo.value;
|
||||
const f = segRange === 0 ? 0 : (val - lo.value) / segRange;
|
||||
const r = Math.round(lo.r + (hi.r - lo.r) * f);
|
||||
const g = Math.round(lo.g + (hi.g - lo.g) * f);
|
||||
const b = Math.round(lo.b + (hi.b - lo.b) * f);
|
||||
ctx.fillStyle = `rgb(${r},${g},${b})`;
|
||||
ctx.fillRect(x + px, y, 1, barH);
|
||||
}
|
||||
|
||||
// border
|
||||
ctx.strokeStyle = '#666';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x, y, barW, barH);
|
||||
|
||||
// tick labels
|
||||
const ticks = [-0.10, -0.06, -0.02, 0.02, 0.06, 0.10];
|
||||
ctx.fillStyle = '#0f172a';
|
||||
ctx.font = `${9 * S}px "Bahnschrift", sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'top';
|
||||
for (const tv of ticks) {
|
||||
const tx = x + ((tv - minVal) / range) * barW;
|
||||
// tick mark
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx, y + barH);
|
||||
ctx.lineTo(tx, y + barH + 3 * S);
|
||||
ctx.strokeStyle = '#666';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.fillText(tv.toFixed(2), tx, y + barH + 4 * S);
|
||||
}
|
||||
}
|
||||
|
||||
export function compositeExportCanvas({
|
||||
mapCanvas, title, legendItems, showLegend, showScaleBar,
|
||||
showNorthArrow, scaleBarInfo, orgName, logoImg,
|
||||
}) {
|
||||
const S = 2;
|
||||
const margin = 32 * S; // 四周白边
|
||||
const mapW = mapCanvas.width;
|
||||
const mapH = mapCanvas.height;
|
||||
const titleH = title ? 72 * S : 0;
|
||||
const footerH = (orgName || logoImg) ? 48 * S : 0;
|
||||
const totalW = mapW + margin * 2;
|
||||
const totalH = titleH + mapH + footerH + margin * 2;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = totalW;
|
||||
canvas.height = totalH;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// white background (always, for clean border)
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, totalW, totalH);
|
||||
|
||||
// outer border
|
||||
ctx.strokeStyle = '#c3cfdf';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(1, 1, totalW - 2, totalH - 2);
|
||||
|
||||
const mapX = margin;
|
||||
const mapY = margin + titleH;
|
||||
|
||||
// title
|
||||
if (title) {
|
||||
ctx.fillStyle = '#0f172a';
|
||||
ctx.font = `bold ${24 * S}px "Bahnschrift", "Segoe UI", sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(title, totalW / 2, margin + titleH / 2);
|
||||
// separator line
|
||||
ctx.strokeStyle = '#d5dfeb';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(margin, mapY - 4 * S);
|
||||
ctx.lineTo(totalW - margin, mapY - 4 * S);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// map content
|
||||
ctx.drawImage(mapCanvas, mapX, mapY);
|
||||
// map border
|
||||
ctx.strokeStyle = '#c3cfdf';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(mapX, mapY, mapW, mapH);
|
||||
|
||||
// north arrow (bigger: 72px base)
|
||||
if (showNorthArrow) {
|
||||
const arrowSize = 72 * S;
|
||||
const pad = 20 * S;
|
||||
const ax = mapX + mapW - arrowSize - pad;
|
||||
const ay = mapY + pad;
|
||||
// white circle background
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(ax + arrowSize / 2, ay + arrowSize / 2, arrowSize / 2 + 4 * S, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.85)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#c3cfdf';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
const tmpCanvas = document.createElement('canvas');
|
||||
tmpCanvas.width = arrowSize;
|
||||
tmpCanvas.height = arrowSize;
|
||||
const tmpCtx = tmpCanvas.getContext('2d');
|
||||
const s = arrowSize / 48;
|
||||
tmpCtx.scale(s, s);
|
||||
tmpCtx.fillStyle = '#1a1a1a';
|
||||
tmpCtx.beginPath();
|
||||
tmpCtx.moveTo(24, 2); tmpCtx.lineTo(30, 22); tmpCtx.lineTo(24, 18); tmpCtx.lineTo(18, 22);
|
||||
tmpCtx.closePath(); tmpCtx.fill();
|
||||
tmpCtx.fillStyle = '#bbb';
|
||||
tmpCtx.strokeStyle = '#1a1a1a';
|
||||
tmpCtx.lineWidth = 0.5;
|
||||
tmpCtx.beginPath();
|
||||
tmpCtx.moveTo(24, 18); tmpCtx.lineTo(30, 22); tmpCtx.lineTo(24, 46); tmpCtx.lineTo(18, 22);
|
||||
tmpCtx.closePath(); tmpCtx.fill(); tmpCtx.stroke();
|
||||
tmpCtx.fillStyle = '#fff';
|
||||
tmpCtx.font = 'bold 12px Arial';
|
||||
tmpCtx.textAlign = 'center';
|
||||
tmpCtx.textBaseline = 'middle';
|
||||
tmpCtx.fillText('N', 24, 11);
|
||||
ctx.drawImage(tmpCanvas, ax, ay);
|
||||
}
|
||||
|
||||
// scale bar (bigger: 200px base width, thicker)
|
||||
if (showScaleBar && scaleBarInfo && scaleBarInfo.barWidthPx > 0) {
|
||||
const barW = scaleBarInfo.barWidthPx * S * 1.4;
|
||||
const barH = 8 * S;
|
||||
const pad = 20 * S;
|
||||
const bx = mapX + mapW - barW - pad;
|
||||
const by = mapY + mapH - pad - barH - 24 * S;
|
||||
// background
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.88)';
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(bx - 8 * S, by - 22 * S, barW + 16 * S, barH + 36 * S, 4 * S);
|
||||
ctx.fill();
|
||||
// label
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
ctx.font = `bold ${14 * S}px "Bahnschrift", sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText(scaleBarInfo.label, bx + barW / 2, by - 4 * S);
|
||||
// bar (alternating black/white segments)
|
||||
ctx.fillStyle = '#1a1a1a';
|
||||
ctx.fillRect(bx, by, barW, barH);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(bx + barW * 0.25, by, barW * 0.25, barH);
|
||||
ctx.fillRect(bx + barW * 0.75, by, barW * 0.25, barH);
|
||||
ctx.strokeStyle = '#1a1a1a';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(bx, by, barW, barH);
|
||||
// end ticks
|
||||
const tickH = 5 * S;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bx, by - tickH); ctx.lineTo(bx, by + barH + tickH);
|
||||
ctx.moveTo(bx + barW, by - tickH); ctx.lineTo(bx + barW, by + barH + tickH);
|
||||
ctx.moveTo(bx + barW / 2, by - tickH); ctx.lineTo(bx + barW / 2, by + barH + tickH);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// legend (bigger fonts and symbols)
|
||||
if (showLegend && legendItems && legendItems.length > 0) {
|
||||
const fontSize = 15 * S;
|
||||
const symSize = 18 * S;
|
||||
const lineH = 28 * S;
|
||||
const padX = 16 * S;
|
||||
const padY = 14 * S;
|
||||
const titleFontSize = 17 * S;
|
||||
const headerH = titleFontSize + padY;
|
||||
const colorbarW = 200 * S;
|
||||
const colorbarH = 16 * S;
|
||||
const colorbarExtraH = colorbarH + 26 * S;
|
||||
|
||||
let contentH = 0;
|
||||
for (const item of legendItems) {
|
||||
contentH += item.type === 'colorbar' ? (lineH + colorbarExtraH) : lineH;
|
||||
}
|
||||
const legendH = headerH + padY + contentH + padY;
|
||||
|
||||
ctx.font = `${fontSize}px "Bahnschrift", sans-serif`;
|
||||
let maxTextW = 0;
|
||||
for (const item of legendItems) {
|
||||
maxTextW = Math.max(maxTextW, ctx.measureText(item.label).width);
|
||||
}
|
||||
const legendW = Math.max(padX + symSize + 10 * S + maxTextW + padX, padX + colorbarW + padX);
|
||||
const lx = mapX + 20 * S;
|
||||
const ly = mapY + mapH - legendH - 20 * S;
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.92)';
|
||||
ctx.strokeStyle = '#c3cfdf';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(lx, ly, legendW, legendH, 8 * S);
|
||||
ctx.fill(); ctx.stroke();
|
||||
ctx.fillStyle = '#0f172a';
|
||||
ctx.font = `bold ${titleFontSize}px "Bahnschrift", sans-serif`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('Legend', lx + padX, ly + padY + titleFontSize / 2);
|
||||
ctx.font = `${fontSize}px "Bahnschrift", sans-serif`;
|
||||
let curY = ly + headerH + padY;
|
||||
legendItems.forEach((item) => {
|
||||
if (item.type === 'colorbar') {
|
||||
ctx.fillStyle = '#0f172a';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(item.label, lx + padX, curY + lineH / 2);
|
||||
curY += lineH;
|
||||
drawColorbar(ctx, lx + padX, curY, colorbarW, colorbarH, S);
|
||||
curY += colorbarExtraH;
|
||||
} else {
|
||||
drawLegendSymbol(ctx, item.type, item.color, item.dash, lx + padX, curY + (lineH - symSize) / 2, symSize);
|
||||
ctx.fillStyle = '#0f172a';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(item.label, lx + padX + symSize + 10 * S, curY + lineH / 2);
|
||||
curY += lineH;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// footer: org name + logo
|
||||
if (orgName || logoImg) {
|
||||
const fy = mapY + mapH + 8 * S;
|
||||
// separator
|
||||
ctx.strokeStyle = '#d5dfeb';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(margin, fy);
|
||||
ctx.lineTo(totalW - margin, fy);
|
||||
ctx.stroke();
|
||||
|
||||
const textY = fy + footerH / 2 - 2 * S;
|
||||
|
||||
// logo on the left
|
||||
if (logoImg) {
|
||||
const logoH = 32 * S;
|
||||
const logoW = Math.round(logoH * (logoImg.naturalWidth / logoImg.naturalHeight));
|
||||
const logoX = margin + 4 * S;
|
||||
const logoY = textY - logoH / 2;
|
||||
ctx.drawImage(logoImg, logoX, logoY, logoW, logoH);
|
||||
// org name after logo
|
||||
if (orgName) {
|
||||
ctx.fillStyle = '#4b5a70';
|
||||
ctx.font = `${14 * S}px "Bahnschrift", "Segoe UI", sans-serif`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(orgName, logoX + logoW + 10 * S, textY);
|
||||
}
|
||||
} else if (orgName) {
|
||||
ctx.fillStyle = '#4b5a70';
|
||||
ctx.font = `${14 * S}px "Bahnschrift", "Segoe UI", sans-serif`;
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(orgName, margin + 4 * S, textY);
|
||||
}
|
||||
|
||||
// date on the right
|
||||
const dateStr = new Date().toLocaleDateString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
ctx.fillStyle = '#6b7a90';
|
||||
ctx.font = `${12 * S}px "Bahnschrift", sans-serif`;
|
||||
ctx.textAlign = 'right';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(dateStr, totalW - margin - 4 * S, textY);
|
||||
}
|
||||
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/* ---------- Blob / 下载 ---------- */
|
||||
|
||||
export function canvasToBlob(canvas, format = 'png', quality = 0.92) {
|
||||
return new Promise((resolve) => {
|
||||
const mime = format === 'jpeg' ? 'image/jpeg' : 'image/png';
|
||||
canvas.toBlob((blob) => resolve(blob), mime, quality);
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadBlob(blob, filename) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/* ---------- 预览缩略图 ---------- */
|
||||
|
||||
export function generatePreviewDataUrl(canvas, maxWidth = 480) {
|
||||
if (!canvas) return '';
|
||||
const ratio = canvas.height / canvas.width;
|
||||
const w = Math.min(maxWidth, canvas.width);
|
||||
const h = Math.round(w * ratio);
|
||||
const tmp = document.createElement('canvas');
|
||||
tmp.width = w;
|
||||
tmp.height = h;
|
||||
const ctx = tmp.getContext('2d');
|
||||
ctx.drawImage(canvas, 0, 0, w, h);
|
||||
return tmp.toDataURL('image/png');
|
||||
}
|
||||
Reference in New Issue
Block a user