/**
* 画个标 · 智能设计引擎(图形标识 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: '',
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: '',
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: '' };
}
/**
* 风格池:用户指定风格优先,其次行业推荐优先,最后全局;始终有效
* @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 };
})();