774 lines
33 KiB
JavaScript
774 lines
33 KiB
JavaScript
/**
|
||
* 画个标 · 智能设计引擎(图形标识 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 };
|
||
})();
|