Files

774 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 画个标 · 智能设计引擎(图形标识 Mark + 品牌组合 Lockup 双层架构)
* 核心理念:
* - 图形标识(Mark):纯图形设计(容器/环/图标/装饰),不含任何文字 —— 这是"LOGO 设计"本身
* - 品牌组合(Lockup):在 Mark 完成之后,把品牌名摆放到图形周边(下方/右侧/上方/环绕)
* - 品牌名绝不"融"进图形内部,只作为周边排版层存在
* 落地 GitHub logo-design-skill 设计方法论:
* - 元素坐标一律以"中心点"传递,杜绝文字/图标偏离画布中心轴
* - 布局 = 图形容器/环/弧/绶带 + 图标的三层构图,而非"换个位置"
* - 深色主色填充的容器(盾/圆/方)承载白色图标,形成徽章化视觉
*/
const HGB_Generator = (function () {
'use strict';
const CW = 600, CH = 600;
let seq = 0;
/* ============ 字体选择 ============ */
// 覆盖风格库 font/fontCn 的全部取值,保证风格字体真正生效(否则 fontFor 回退默认)
const CN_FONTS = ['SimHei', 'Microsoft YaHei', 'SimSun', 'KaiTi', '宋体', '微软雅黑', '楷体', '黑体'];
const EN_FONTS = ['Arial', 'Georgia', 'Impact', 'Verdana', 'Montserrat', 'Playfair Display',
'Comic Sans MS', 'Times New Roman', 'Bradley Hand', 'Arial Black'];
/** 根据文本内容与行业推荐字体,选择合适字体 */
function fontFor(text, favs) {
const hasCn = /[\u4e00-\u9fa5]/.test(text || '');
const pool = hasCn ? CN_FONTS : EN_FONTS;
const fav = (favs || []).find(f => pool.includes(f));
return fav || (hasCn ? 'SimHei' : 'Arial');
}
/* ============ 文字测量与自适应(防"破版"核心) ============ */
/** 估算文本渲染宽度:中文全角、西文按 0.55 倍、计入字距 */
function textWidth(text, fontSize, spacing) {
const t = String(text || '');
const cn = (t.match(/[\u4e00-\u9fa5]/g) || []).length;
const other = t.length - cn;
return cn * fontSize + other * fontSize * 0.55 + Math.max(0, t.length - 1) * (spacing || 0);
}
/** 从基准字号向下收缩,直到文本能放进 maxWidth(下限 min) */
function fitFontSize(text, maxWidth, base, spacing, min) {
const t = String(text || '');
if (!t) return base;
const mn = min || 18;
let size = base;
while (size > mn && textWidth(t, size, spacing || 0) > maxWidth) size -= 2;
return Math.round(size);
}
/** 超宽文本截断加省略号 */
function clipText(text, maxWidth, size, spacing) {
const t = String(text || '');
if (!t || textWidth(t, size, spacing || 0) <= maxWidth) return t;
let cut = t;
while (cut.length > 1 && textWidth(cut + '…', size, spacing || 0) > maxWidth) cut = cut.slice(0, -1);
return cut + '…';
}
/** 品牌名是否适合环形环绕等紧凑排版(中文 ≤6 字或西文 ≤12 字符) */
function isShort(brand) {
const t = String(brand || '').trim();
const cn = (t.match(/[\u4e00-\u9fa5]/g) || []).length;
return cn ? cn <= 6 : t.length <= 12;
}
/** 按品牌名长度选择默认 Mark 布局 */
function defaultLayout(brand) {
return LAYOUTS.find(l => l.id === 'orb') || LAYOUTS[0];
}
/** 风格倾向布局:取风格 layouts 列表第一个有效布局(可再按白名单过滤;无则 null) */
function favLayout(style, whitelist) {
let favs = (style && style.layouts || []).filter(id => LAYOUTS.some(l => l.id === id));
if (whitelist && whitelist.length) favs = favs.filter(id => whitelist.includes(id));
// LOGO 模式(无白名单)排除头像专有布局
else favs = favs.filter(id => !(LAYOUTS.find(l => l.id === id) || {}).avatarOnly);
if (!favs.length) return null;
return LAYOUTS.find(l => l.id === favs[0]) || null;
}
/** 布局兜底:白名单优先轮换,否则全局轮换(排除头像专有布局) */
function layoutFallback(i, whitelist) {
if (whitelist && whitelist.length) {
const l = LAYOUTS.find(x => x.id === whitelist[i % whitelist.length]);
if (l) return l;
}
const pool = LAYOUTS.filter(l => !l.avatarOnly);
return pool[i % pool.length];
}
/* ============ 元素构造(一律以"中心点"定位) ============ */
function iconEl(ic, cx, cy, w, h, stroke, strokeW) {
return {
id: uid('ic'), type: 'icon',
name: (ic && ic.name) || '图标', iconId: (ic && ic.id) || '',
svg: (ic && ic.svg) || '',
// 多色素材(emoji/品牌):preserve=true 时导出/预览保留原始颜色,不按 stroke/fill 覆盖
preserve: !!(ic && ic.type && ic.type !== 'icon'),
x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h, rotation: 0,
fill: 'none', stroke, strokeWidth: strokeW,
opacity: 1, visible: true, locked: false
};
}
function ringEl(cx, cy, w, h, stroke, strokeW) {
return {
id: uid('rg'), type: 'icon', name: '圆环', iconId: 'ring',
svg: '<circle cx="12" cy="12" r="10"/>',
x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h, rotation: 0,
fill: 'none', stroke, strokeWidth: strokeW,
opacity: 1, visible: true, locked: false
};
}
function textEl(text, cx, cy, w, h, fontFamily, fontSize, fontWeight, fill, letterSpacing) {
return {
id: uid('tx'), type: 'text', name: '文字', text,
x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h, rotation: 0,
fontSize, fontWeight, fontFamily, letterSpacing,
fill, stroke: 'none', strokeWidth: 0,
opacity: 1, visible: true, locked: false
};
}
/** 环形文字(textPath,品牌名环绕 Mark 用) */
function arcTextEl(text, arc, fontFamily, fontSize, fontWeight, fill, letterSpacing) {
return {
id: uid('at'), type: 'text', name: '环形文字', text,
x: Math.round(arc.cx - arc.r), y: Math.round(arc.cy - arc.r),
w: Math.round(arc.r * 2), h: Math.round(arc.r * 2), rotation: 0,
fontSize, fontWeight, fontFamily, letterSpacing,
fill, stroke: 'none', strokeWidth: 0,
arc, opacity: 1, visible: true, locked: false
};
}
/** 装饰原语(中心定位包装) */
function decoAt(decId, cx, cy, w, h, color, grad) {
return HGB_Decorations.decoEl(decId, Math.round(cx - w / 2), Math.round(cy - h / 2), w, h, color, grad);
}
/** 色条:铺满元素的圆角矩形 */
function barEl(cx, cy, w, h, color, grad) {
return {
id: uid('br'), type: 'icon', name: '色条', iconId: 'deco-bar',
svg: '<rect x="0" y="0" width="24" height="24" rx="12"/>',
x: Math.round(cx - w / 2), y: Math.round(cy - h / 2), w, h, rotation: 0,
fill: grad ? 'url(#' + grad.id + ')' : color,
stroke: 'none', strokeWidth: 0, grad: grad || null,
opacity: 1, visible: true, locked: false
};
}
function uid(prefix) {
return prefix + '_' + Date.now().toString(36) + (seq++).toString(36);
}
/* ============ 渐变 ============ */
/** 主色线性渐变(容器填充用) */
function mainGrad(palette) {
return {
id: 'g_' + Date.now().toString(36) + (seq++).toString(36),
type: 'linear',
from: palette.colors[0],
to: HGB_Decorations.lighten(palette.colors[0], 0.30)
};
}
/** 径向渐变(圆标用,中心亮边缘深) */
function radialGrad(palette) {
return {
id: 'g_' + Date.now().toString(36) + (seq++).toString(36),
type: 'radial',
from: HGB_Decorations.lighten(palette.colors[0], 0.42),
to: palette.colors[0]
};
}
/** 首字符(字标用):中文取首字,西文取大写首字母 */
function initialOf(text) {
const t = String(text || '').trim();
if (!t) return 'A';
const ch = t[0];
return /[\u4e00-\u9fa5]/.test(ch) ? ch : ch.toUpperCase();
}
/* ============ 图形标识模板(Mark,只出纯图形,无文字) ============ */
/**
* 每个模板 build(opts) 返回 mark 图形元素数组
* opts = { brand, sub, icon, icon2, palette, style, fontCn, fontEn, strokeW, cx, cy }
* cx/cy = 图形中心(默认 300,300);palette.colors = [主色, 辅助色, 点缀色, 背景]
* round = true 表示圆形构图(可配环形环绕 Lockup)
*/
const LAYOUTS = [
{
id: 'badge', name: '双环徽章', emoji: '🎖️', round: true,
desc: '外环+内双线环承载图标,权威与荣耀',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c0 = palette.colors[0], c1 = palette.colors[1];
const els = [];
els.push(ringEl(cx0, cy0, 380, 380, c1, strokeW * 1.5)); // 外环 r=190
els.push(decoAt('deco-doublering', cx0, cy0, 280, 280, c1, null)); // 内双线环 r≈98/65
els.push(iconEl(icon, cx0, cy0, 128, 128, c0, strokeW)); // 图标居中
return els;
}
},
{
id: 'seal', name: '双环印章', emoji: '🕹️', round: true,
desc: '官方认证感,双环+星点',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c0 = palette.colors[0], c1 = palette.colors[1], c2 = palette.colors[2];
const els = [];
els.push(ringEl(cx0, cy0, 400, 400, c1, strokeW * 2)); // 外环 r=200
els.push(ringEl(cx0, cy0, 320, 320, c1, strokeW)); // 内环 r=160
els.push(decoAt('deco-shape-diamond', cx0, cy0 + 150, 26, 26, c2, null)); // 底部星点
els.push(iconEl(icon, cx0, cy0 - 10, 110, 106, c0, strokeW));
return els;
}
},
{
id: 'emblem', name: '渐变盾徽', emoji: '🛡️',
desc: '渐变盾形承载白色图标,学院派权威',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const g = mainGrad(palette);
const els = [];
els.push(decoAt('deco-shape-shield', cx0, cy0, 360, 360, null, g)); // 渐变盾形
els.push(iconEl(icon, cx0, cy0, 126, 126, '#FFFFFF', strokeW)); // 白色图标
return els;
}
},
{
id: 'orb', name: '圆标容器', emoji: '🔮', round: true,
desc: '径向渐变圆承载白色图标,现代应用图标风',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c1 = palette.colors[1];
const g = radialGrad(palette);
const els = [];
els.push(decoAt('deco-shape-circle', cx0, cy0, 320, 320, null, g)); // 径向渐变圆 r=160
els.push(iconEl(icon, cx0, cy0, 130, 130, '#FFFFFF', strokeW * 1.2));
els.push(decoAt('deco-dotring', cx0, cy0, 380, 380, c1, null)); // 点状光环 r≈170
return els;
}
},
{
id: 'monogram', name: '首字字标', emoji: '🔠',
desc: '渐变圆角容器承载品牌首字,字标风',
build(opts) {
const { brand, palette, fontCn, fontEn, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c2 = palette.colors[2];
const g = mainGrad(palette);
const letter = initialOf(brand);
const els = [];
els.push(decoAt('deco-shape-squircle', cx0, cy0, 280, 280, null, g)); // 渐变圆角容器
const lf = /[\u4e00-\u9fa5]/.test(letter) ? fontFor(brand, [fontCn, fontEn]) : fontFor(brand, [fontEn, fontCn]);
const lfs = fitFontSize(letter, 200, 158, 0, 64);
els.push(textEl(letter, cx0, cy0, 200, 190, lf, lfs, 800, '#FFFFFF', 0)); // 白色大字
els.push(decoAt('deco-shape-diamond', cx0, cy0 + 132, 30, 30, c2, null)); // 底部点缀菱形
return els;
}
},
{
id: 'ribbon', name: '绶带横幅', emoji: '🎗️',
desc: '主色绶带+图标,庆典仪式感',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c0 = palette.colors[0], c2 = palette.colors[2];
const els = [];
els.push(decoAt('deco-arc-top', cx0, cy0 - 62, 280, 160, c2, null)); // 顶部弧线
els.push(iconEl(icon, cx0, cy0 - 68, 140, 140, c0, strokeW * 1.3));
els.push(decoAt('deco-shape-ribbon', cx0, cy0 + 82, 460, 150, c0, null)); // 绶带(主色填充,无文字)
els.push(decoAt('deco-shape-pill', cx0, cy0 + 176, 210, 40, c2, null)); // 下方点缀胶囊
return els;
}
},
{
id: 'hexagon', name: '六边形徽章', emoji: '⬡',
desc: '渐变六边形承载白色图标,秩序感',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const g = mainGrad(palette);
const c2 = palette.colors[2];
const els = [];
els.push(decoAt('deco-shape-hexagon', cx0, cy0, 320, 320, null, g));
els.push(iconEl(icon, cx0, cy0, 126, 126, '#FFFFFF', strokeW * 1.1));
els.push(decoAt('deco-shape-diamond', cx0, cy0 + 156, 22, 22, c2, null));
return els;
}
},
{
id: 'diamond', name: '菱形徽章', emoji: '💎',
desc: '渐变菱形承载白色图标,精品感',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const g = mainGrad(palette);
const c2 = palette.colors[2];
const els = [];
els.push(decoAt('deco-shape-diamond', cx0, cy0, 320, 320, null, g));
els.push(iconEl(icon, cx0, cy0, 120, 120, '#FFFFFF', strokeW * 1.1));
els.push(decoAt('deco-shape-circle', cx0, cy0 + 152, 18, 18, c2, null));
return els;
}
},
{
id: 'techcorner', name: '科技角标', emoji: '🪐',
desc: '对角括号框定图标+光芒,科技秩序感',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c1 = palette.colors[1], c2 = palette.colors[2];
const els = [];
els.push(decoAt('deco-corner-tl', cx0 - 230, cy0 - 230, 140, 140, c1, null));
els.push(decoAt('deco-corner-br', cx0 + 230, cy0 + 230, 140, 140, c1, null));
els.push(decoAt('deco-spark', cx0 + 168, cy0 - 182, 48, 48, c2, null));
els.push(iconEl(icon, cx0, cy0, 140, 140, c1, strokeW * 1.2));
return els;
}
},
{
id: 'duo', name: '双图标对称', emoji: '🪞', round: true,
desc: '双图标对称弧拱,均衡呼应',
build(opts) {
const { icon, icon2, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c1 = palette.colors[1], c2 = palette.colors[2];
const els = [];
els.push(decoAt('deco-arc-top', cx0, cy0 - 88, 320, 170, c1, null)); // 顶部连接弧
els.push(iconEl(icon, cx0 - 152, cy0 + 18, 124, 124, c1, strokeW));
els.push(iconEl(icon2 || icon, cx0 + 152, cy0 + 18, 124, 124, c1, strokeW));
els.push(decoAt('deco-shape-diamond', cx0, cy0 - 176, 30, 30, c2, null));
return els;
}
},
{
id: 'arch', name: '拱门徽章', emoji: '🏛️',
desc: '渐变拱门承载白色图标,学院殿堂感',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const g = mainGrad(palette);
const c2 = palette.colors[2];
const els = [];
els.push(decoAt('deco-shape-arch', cx0, cy0, 300, 330, null, g)); // 渐变拱形衬底
els.push(iconEl(icon, cx0, cy0 + 16, 118, 118, '#FFFFFF', strokeW * 1.1));
els.push(decoAt('deco-shape-diamond', cx0, cy0 + 152, 26, 26, c2, null)); // 底部点缀
return els;
}
},
{
id: 'burst', name: '光芒徽章', emoji: '☀️', round: true,
desc: '射线环+渐变圆承载图标,光芒与能量',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c1 = palette.colors[1];
const g = radialGrad(palette);
const els = [];
els.push(decoAt('deco-rayring', cx0, cy0, 396, 396, c1, null)); // 12 根射线环 r≈198
els.push(decoAt('deco-shape-circle', cx0, cy0, 264, 264, null, g)); // 径向渐变圆 r=132
els.push(iconEl(icon, cx0, cy0, 116, 116, '#FFFFFF', strokeW * 1.1));
return els;
}
},
{
id: 'geo', name: '几何构形', emoji: '📐',
desc: '圆环+渐变三角黄金叠加,精确几何感',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c1 = palette.colors[1], c2 = palette.colors[2];
const g = mainGrad(palette);
const els = [];
els.push(ringEl(cx0, cy0, 400, 400, c1, strokeW * 1.8)); // 外圆环 r=200
els.push(decoAt('deco-shape-triangle', cx0, cy0 + 12, 300, 280, null, g)); // 渐变三角衬底
els.push(iconEl(icon, cx0, cy0 + 8, 116, 116, '#FFFFFF', strokeW * 1.1));
els.push(decoAt('deco-shape-diamond', cx0, cy0 + 116, 22, 22, c2, null));
return els;
}
},
/* ===== 头像专有布局(avatarOnly:仅头像模式白名单使用,LOGO 模式全局池排除) ===== */
{
id: 'fullbleed', name: '全出血图标', emoji: '🖼️', avatarOnly: true,
desc: '无容器,图标直接顶满画布,纯图形大图标头像',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c0 = palette.colors[0];
const els = [];
els.push(iconEl(icon, cx0, cy0, 232, 232, c0, strokeW * 2)); // 大图标占 77%
return els;
}
},
{
id: 'concentric', name: '同心圆环', emoji: '🌀', avatarOnly: true,
desc: '三重同心圆环+中心图标,韵律感头像',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c1 = palette.colors[1], c2 = palette.colors[2];
const els = [];
els.push(ringEl(cx0, cy0, 400, 400, c1, strokeW * 1.2)); // 外环 r=200
els.push(ringEl(cx0, cy0, 330, 330, c1, strokeW * 0.8)); // 中环 r=165
els.push(ringEl(cx0, cy0, 240, 240, c2, strokeW)); // 内环 r=120
els.push(iconEl(icon, cx0, cy0, 120, 120, c1, strokeW * 1.1));
return els;
}
},
{
id: 'centroid', name: '中心单环', emoji: '⭕', avatarOnly: true,
desc: '细圆环+居中大图标,简洁单图标头像',
build(opts) {
const { icon, palette, strokeW, cx, cy } = opts;
const cx0 = cx || 300, cy0 = cy || 300;
const c1 = palette.colors[1];
const els = [];
els.push(ringEl(cx0, cy0, 372, 372, c1, strokeW * 1.3)); // 外环 r=186
els.push(iconEl(icon, cx0, cy0, 158, 158, c1, strokeW * 1.5)); // 大图标
return els;
}
}
];
/* ============ 品牌组合位置(Lockup) ============ */
/**
* 品牌名在 Mark 图形周边的摆放方式
* pos: below(下方居中) / right(右侧并排) / top(上方居中) / wrap(环形环绕)
*/
const LOCKUP_POS = [
{ pos: 'below', name: '下方居中' },
{ pos: 'right', name: '右侧并排' },
{ pos: 'top', name: '上方居中' },
{ pos: 'wrap', name: '环形环绕' }
];
/** 计算 Mark 的图形元素(以指定中心生成一次,供 Lockup 复用) */
function buildMark(layout, opts, cx, cy) {
return layout.build({ ...opts, cx, cy });
}
/**
* 生成单个 Lockup 组合(Mark + 品牌名在 pos 位置)
* @returns {Array} 完整元素数组
*/
function buildLockup(layout, pos, opts) {
const brand = opts.brand;
const sub = opts.sub;
const palette = opts.palette;
const c0 = palette.colors[0], c1 = palette.colors[1], c2 = palette.colors[2];
const fontCn = opts.fontCn, fontEn = opts.fontEn;
const els = [];
if (pos === 'wrap') {
// 环形环绕:仅圆形 Mark + 短品牌名
els.push(...buildMark(layout, opts, 300, 300));
const rOut = 214;
const fs = fitFontSize(brand, rOut * 2, 36, 3, 18);
els.push(arcTextEl(brand,
{ cx: 300, cy: 300, r: rOut, start: 180, sweep: 180 },
fontFor(brand, [fontCn, fontEn]), fs, 700, c0, 3));
if (sub) {
const sfs = fitFontSize(sub, rOut * 1.6, 15, 3, 11);
// 副标题走"下半弧":start=180 起、逆时针(负sweep)经过底部到 0°,文字沿底部自左向右正立
els.push(arcTextEl(clipText(sub, rOut * 1.6, sfs, 3),
{ cx: 300, cy: 300, r: rOut, start: 180, sweep: -180 },
fontFor(sub, [fontEn, fontCn]), sfs, 400, c1, 3));
}
return els;
}
if (pos === 'below') {
// Mark 偏上居中,品牌名在正下方
els.push(...buildMark(layout, opts, 300, 246));
els.push(barEl(300, 470, 120, 8, c2));
const MAXW = 460;
const fs = fitFontSize(brand, MAXW, 46, 3, 22);
els.push(textEl(brand, 300, 512, MAXW, 62, fontFor(brand, [fontCn, fontEn]), fs, 700, c0, 3));
if (sub) {
const sfs = fitFontSize(sub, 380, 16, 3, 12);
els.push(textEl(clipText(sub, 380, sfs, 3), 300, 560, 380, 24,
fontFor(sub, [fontEn, fontCn]), sfs, 400, c1, 3));
}
return els;
}
if (pos === 'right') {
// Mark 居左,品牌名右侧左对齐
els.push(...buildMark(layout, opts, 232, 300));
const MAXW = 150;
const fs = fitFontSize(brand, MAXW, 42, 1, 18);
const tw = textWidth(brand, fs, 1);
const cxT = 430 + tw / 2;
els.push(textEl(brand, cxT, 286, MAXW, 58, fontFor(brand, [fontCn, fontEn]), fs, 700, c0, 1));
els.push(barEl(cxT, 352, 60, 7, c2));
if (sub) {
const sfs = fitFontSize(sub, MAXW, 14, 2, 11);
els.push(textEl(clipText(sub, MAXW, sfs, 2), cxT, 392, MAXW, 22,
fontFor(sub, [fontEn, fontCn]), sfs, 400, c1, 2));
}
return els;
}
// top:Mark 偏下居中,品牌名在正上方
els.push(...buildMark(layout, opts, 300, 356));
els.push(barEl(300, 222, 120, 8, c2));
const MAXW = 460;
const fs = fitFontSize(brand, MAXW, 42, 3, 22);
els.push(textEl(brand, 300, 178, MAXW, 58, fontFor(brand, [fontCn, fontEn]), fs, 700, c0, 3));
if (sub) {
const sfs = fitFontSize(sub, 380, 15, 3, 12);
els.push(textEl(clipText(sub, 380, sfs, 3), 300, 132, 380, 22,
fontFor(sub, [fontEn, fontCn]), sfs, 400, c1, 3));
}
return els;
}
/* ============ 方案生成 ============ */
/**
* 组合单个方案(供批量生成与变体微调复用)
* @returns {{id,name,layoutId,layoutName,paletteName,styleId,bg,
* markElements:Array, lockups:Array, elements:Array}}
*/
function composePlan(opt, layout, palette, style) {
const brand = opt.brand;
const sub = opt.sub;
const icon1 = opt.icon1;
const icon2 = opt.icon2;
const fontFavs = opt.fontFavs || [];
// 视觉描边宽度(屏幕像素):配合 exporter 的 non-scaling-stroke 恒定不随放大变粗
const strokeW = Math.max(3, Math.round(((style ? style.strokeWidth : 1.6) || 1.6) * 2));
const mkOpts = {
brand, sub,
icon: icon1,
icon2: icon2,
palette,
style,
// 风格字体优先(风格自包含),行业/AI 字体建议兜底
fontCn: (style && style.fontCn && CN_FONTS.includes(style.fontCn))
? style.fontCn
: (fontFavs.find(f => CN_FONTS.includes(f)) || (style && style.fontCn) || 'SimHei'),
fontEn: (style && style.font && EN_FONTS.includes(style.font))
? style.font
: (fontFavs.find(f => EN_FONTS.includes(f)) || (style && style.font) || 'Arial'),
strokeW
};
// 图形标识(Mark):纯图形,居中展示
const markElements = buildMark(layout, mkOpts, 300, 300);
// 品牌组合(Lockup):品牌名在图形周边各位置摆放
const lockups = LOCKUP_POS.filter(lp =>
lp.pos !== 'wrap' || (layout.round && isShort(brand))
).map(lp => ({
pos: lp.pos,
name: lp.name,
elements: buildLockup(layout, lp.pos, mkOpts)
}));
return {
id: 'plan_' + Date.now().toString(36) + (seq++).toString(36),
name: `${layout.name} · ${palette.name}`,
layoutId: layout.id,
layoutName: layout.name,
paletteName: palette.name,
styleId: style ? style.id : '',
bg: palette.colors[3] || '#FFFFFF',
markElements, // 区域一:图形标识(纯图形)
lockups, // 区域二:品牌组合(各位置)
elements: lockups.length ? lockups[0].elements : markElements // 默认下方组合
};
}
/** 收集选中图标池(去重,含行业图标;供批量生成轮换使用) */
function pickIcons(opt, ind) {
const picked = (opt.icons || []).filter(Boolean).slice(0, 5);
const indIcons = (ind.icons || []).filter(i => !picked.some(p => p.id === i.id));
const pool = [...picked, ...indIcons];
const iconPool = pool.filter((v, i) => v && pool.findIndex(x => x && x.id === v.id) === i);
return {
icon1: iconPool[0],
icon2: iconPool[1] || iconPool[0],
iconPool: iconPool.length ? iconPool : [icon1Fallback(ind)]
};
}
/** 行业图标兜底(找不到任何图标时) */
function icon1Fallback(ind) {
return (ind.icons || [])[0] || { id: 'star', name: '星形', svg: '<path d="M12 2l3.1 6.3 7 1-5.1 5 1.2 7L12 17.5 5.8 21.3l1.2-7-5.1-5 7-1z"/>' };
}
/**
* 风格池:用户指定风格优先,其次行业推荐优先,最后全局;始终有效
* @param {object} ind 行业对象(含 styleIds)
* @param {Array} [styleIds] 用户选中的风格 id(空/无效则全部)
*/
function stylePoolFor(ind, styleIds) {
const indIds = (ind.styleIds || []);
let pool = HGB_Styles.slice();
if (styleIds && styleIds.length) {
const wanted = pool.filter(s => styleIds.includes(s.id));
if (wanted.length) pool = wanted; // 全部无效则回退全部风格
}
// 行业推荐优先排序
return pool.sort((a, b) => (indIds.includes(b.id) ? 1 : 0) - (indIds.includes(a.id) ? 1 : 0));
}
/**
* 批量生成方案
* @param {object} opt
* industry: 行业对象(含 palettes/styleIds/fontFamilies/icons)
* brand: 品牌名(默认取行业默认名称或用户输入)
* sub: 副标题(可空)
* icons: 用户选中的图标数组 [{id,name,svg}],可为空(用行业图标轮换)
* styleIds: 用户选中的风格方向 id(空 = 全部风格)
* iconLocked: 图标驱动模式——每个选中图标固定作为核心图形,各产出 count 套(总方案 = 图标数 × count)
* count: 生成数量(图标驱动时 = 每个图标的套数)
* @returns {Array} 方案数组
*/
function generatePlans(opt) {
const ind = opt.industry || HGB_Industries[0];
const brand = (opt.brand || '').trim() || sampleName(ind);
const sub = (opt.sub || '').trim();
const count = Math.max(1, Math.min(20, opt.count || 6));
// 风格池:用户指定 > 行业推荐 > 全局
const stylePool = stylePoolFor(ind, opt.styleIds);
// 风格顺序洗牌:默认 5 套尽量覆盖不同风格,避免"同风格扎堆"
const styleOrder = shuffle(stylePool.slice());
// 布局白名单(可选):限定 Mark 布局范围(头像模式排除带文字的 monogram/ribbon)
const layoutWhitelist = (opt.layoutWhitelist || []).filter(id => LAYOUTS.some(l => l.id === id));
// 每风格的倾向布局列表(预洗牌,保证每次生成构图有变化)
const styleLayouts = {};
for (const s of stylePool) {
let favs = (s.layouts || []).filter(id => LAYOUTS.some(l => l.id === id));
if (layoutWhitelist.length) favs = favs.filter(id => layoutWhitelist.includes(id));
// LOGO 模式(无白名单)排除头像专有布局
else favs = favs.filter(id => !(LAYOUTS.find(l => l.id === id) || {}).avatarOnly);
styleLayouts[s.id] = shuffle(favs);
}
const fontFavs = ind.fontFamilies || [];
// 图标驱动模式:每个选中图标固定作为核心图形,各产出 count 套(icon1=icon2=该图标)
const lockedIcons = opt.iconLocked
? (opt.icons || []).filter(Boolean).slice(0, 5)
: [];
if (lockedIcons.length) {
const plans = [];
for (const locked of lockedIcons) {
for (let j = 0; j < count; j++) {
const i = plans.length;
const style = styleOrder[i % styleOrder.length];
// 配色始终跟随风格色系(消除"极简风配行业蓝"式冲淡)
const palette = {
name: style.name,
colors: [style.colors.primary, style.colors.secondary, style.colors.accent, style.colors.bg]
};
// 布局:有白名单时按白名单全集轮换;否则按风格倾向优先轮换
const favs = styleLayouts[style.id];
const layout = layoutWhitelist.length
? layoutFallback(i, layoutWhitelist)
: (favs.length
? (LAYOUTS.find(l => l.id === favs[i % favs.length]) || layoutFallback(i, layoutWhitelist))
: layoutFallback(i, layoutWhitelist));
const plan = composePlan({
brand, sub, icon1: locked, icon2: locked, fontFavs
}, layout, palette, style);
// 记录锁定图标:变体微调(换配色/换布局)时保持同一图标
plan.lockedIcon = { id: locked.id, name: locked.name, svg: locked.svg, source: locked.source || '' };
plans.push(plan);
}
}
return plans;
}
const { iconPool } = pickIcons(opt, ind);
const plans = [];
for (let i = 0; i < count; i++) {
// 风格优先:每套方案由风格决定配色/背景/线宽/字体/构图
const style = styleOrder[i % styleOrder.length];
// 配色始终跟随风格色系(消除"极简风配行业蓝"式冲淡)
const palette = {
name: style.name,
colors: [style.colors.primary, style.colors.secondary, style.colors.accent, style.colors.bg]
};
// 布局:有白名单时按白名单全集轮换(保证头像专有布局被覆盖);
// 否则按风格倾向优先轮换,无倾向则从全局轮换
const favs = styleLayouts[style.id];
const layout = layoutWhitelist.length
? layoutFallback(i, layoutWhitelist)
: (favs.length
? (LAYOUTS.find(l => l.id === favs[i % favs.length]) || layoutFallback(i, layoutWhitelist))
: layoutFallback(i, layoutWhitelist));
// 图标轮换:每套方案使用不同图标,避免"全套一个图标"的雷同感
const a = iconPool[i % iconPool.length];
const b = iconPool[(i + 1) % iconPool.length];
plans.push(composePlan({
brand, sub, icon1: a, icon2: b, fontFavs
}, layout, palette, style));
}
return plans;
}
/**
* 生成单个变体(预览弹窗内微调用)
* @param {object} opt 与 generatePlans 相同,额外支持:
* layoutId: 指定 Mark 模板 id(不传则取风格倾向布局)
* styleId: 指定风格 id(不传则行业优先)
* 说明:配色不再独立传入,始终跟随 styleId 对应风格的色系(风格自包含)
* @returns {object} 单个方案
*/
function generateVariant(opt) {
const ind = opt.industry || HGB_Industries[0];
const brand = (opt.brand || '').trim() || sampleName(ind);
const sub = (opt.sub || '').trim();
const { icon1, icon2 } = pickIcons(opt, ind);
// 字体偏好:AI 决策优先,其次行业推荐
const fontFavs = (opt.fontFavs && opt.fontFavs.length)
? opt.fontFavs
: (ind.fontFamilies || []);
// 风格池:用户指定 > 行业推荐 > 全局
const stylePool = stylePoolFor(ind, opt.styleIds);
// 目标风格:指定或行业优先
const style = opt.styleId
? (stylePool.find(s => s.id === opt.styleId) || stylePool[0])
: stylePool[0];
// 配色始终跟随风格色系(风格自包含:颜色/背景/字重/线宽随风格整体切换)
const palette = {
name: style.name,
colors: [style.colors.primary, style.colors.secondary, style.colors.accent, style.colors.bg]
};
// 目标布局:指定且合法,否则取风格倾向布局(受白名单约束)
const layoutWhitelist = (opt.layoutWhitelist || []).filter(id => LAYOUTS.some(l => l.id === id));
const layout = opt.layoutId
? (LAYOUTS.find(l => l.id === opt.layoutId) || layoutFallback(0, layoutWhitelist))
: (favLayout(style, layoutWhitelist) || layoutFallback(0, layoutWhitelist));
const plan = composePlan({ brand, sub, icon1, icon2, fontFavs }, layout, palette, style);
// 应用指定 Lockup 位置(默认 elements 为第一个位置)
if (opt.lockup) {
const hit = plan.lockups.find(lp => lp.pos === opt.lockup);
if (hit) plan.elements = hit.elements;
}
return plan;
}
/** 行业默认示例品牌名 */
function sampleName(ind) {
const map = {
edu: 'EDUCATION', food: '美食工坊', health: '仁心医疗',
tech: 'NEXUS', sport: '锐动体育', finance: '恒信资本'
};
return map[ind.id] || ind.name || '我的品牌';
}
/** 洗牌 */
function shuffle(arr) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
return { generatePlans, generateVariant, sampleName, defaultLayout, isShort, LAYOUTS, LOCKUP_POS, CW, CH };
})();