nutritionFactsLayout.ts 8.78 KB
/**
 * US Nutrition Facts 面板布局:三列(名称 / 含量 / %DV)、粗细分隔线、可选 "<" 前缀。
 */
export type NutritionDivider = 'none' | 'thin' | 'double';

export type NutritionLayoutRowDef = {
  key: string;
  label: string;
  defaultUnit: string;
  labelBold?: boolean;
  indent?: boolean;
  dividerAfter?: NutritionDivider;
};

/** 模板固定营养成分行(图2顺序与文案;大类 dividerAfter=double,其他=thin) */
export const NUTRITION_FACTS_LAYOUT_ROWS: readonly NutritionLayoutRowDef[] = [
  { key: 'fat', label: 'Total Fat', defaultUnit: 'g', labelBold: true, dividerAfter: 'double' },
  { key: 'transFat', label: 'Trans Fat', defaultUnit: 'g', dividerAfter: 'thin' },
  { key: 'cholesterol', label: 'Cholesterol', defaultUnit: 'mg', labelBold: true, dividerAfter: 'double' },
  { key: 'sodium', label: 'Sodium', defaultUnit: 'mg', labelBold: true, dividerAfter: 'double' },
  { key: 'carbs', label: 'Total Carbo.', defaultUnit: 'g', labelBold: true, dividerAfter: 'double' },
  { key: 'totalSugar', label: 'Sugars', defaultUnit: 'g', dividerAfter: 'thin' },
  { key: 'dietaryFiber', label: 'Dietary Fiber', defaultUnit: 'g', dividerAfter: 'thin' },
  { key: 'protein', label: 'Protein', defaultUnit: 'g', labelBold: true, dividerAfter: 'double' },
  { key: 'calcium', label: 'Calcium', defaultUnit: 'mg', dividerAfter: 'thin' },
  { key: 'potassium', label: 'Potassium', defaultUnit: 'mg', dividerAfter: 'thin' },
  { key: 'vitaminA', label: 'Vitamin A', defaultUnit: 'mg', dividerAfter: 'thin' },
  { key: 'vitaminD', label: 'Vitamin D', defaultUnit: 'mg', dividerAfter: 'thin' },
  { key: 'iron', label: 'Iron', defaultUnit: 'mg', dividerAfter: 'none' },
] as const;

/** 含量 / %DV 列固定宽度(全表共用,保证各行垂直对齐) */
export const NUTRITION_AMOUNT_COL_WIDTH = '4rem';
export const NUTRITION_PCT_COL_WIDTH = '2.75rem';

/** 图2 正文区字号(Servings ~ 底部维生素行) */
export const NUTRITION_BODY_FONT_SIZE = 12;

export const DEFAULT_NUTRITION_FOOTER_NOTE =
  '* Percent Daily Values are based on a 2000 calorie diet';

export type NutritionFactsRowView = {
  key: string;
  label: string;
  amountText: string;
  dailyValueText: string;
  labelBold: boolean;
  indent: boolean;
  dividerAfter: NutritionDivider;
};

export type NutritionFactsViewModel = {
  titleFontSize: number;
  servingsLabel: string;
  servingsValue: string;
  servingSizeLabel: string;
  servingSizeValue: string;
  caloriesLabel: string;
  caloriesValue: string;
  caloriesAmountText: string;
  rows: NutritionFactsRowView[];
  footerNote: string;
  ingredientsText: string;
};

function cfgStr(cfg: Record<string, unknown>, keys: string[], fallback = ''): string {
  for (const k of keys) {
    const v = cfg[k];
    if (v != null && String(v).trim() !== '') return String(v).trim();
  }
  return fallback;
}

function cfgBool(cfg: Record<string, unknown>, keys: string[]): boolean {
  for (const k of keys) {
    const v = cfg[k];
    if (v === true || v === 'true' || v === 1 || v === '1') return true;
    if (v === false || v === 'false' || v === 0 || v === '0') return false;
  }
  return false;
}

function fixedRows(cfg: Record<string, unknown>): Record<string, unknown>[] {
  return Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record<string, unknown>[]) : [];
}

function rowFromFixed(cfg: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
  return fixedRows(cfg).find((r) => String(r.key ?? '').trim() === key);
}

export function readNutritionLessThan(cfg: Record<string, unknown>, key: string): boolean {
  const row = rowFromFixed(cfg, key);
  if (row && row.lessThan != null) return cfgBool({ lessThan: row.lessThan }, ['lessThan']);
  return cfgBool(cfg, [`${key}LessThan`, `${key}UseLessThan`]);
}

export function nutritionFixedField(
  cfg: Record<string, unknown>,
  key: string,
  field: 'value' | 'unit' | 'dailyValuePercent',
): string {
  const row = rowFromFixed(cfg, key);
  if (field === 'dailyValuePercent') {
    const fromRow = row?.dailyValuePercent ?? row?.percent ?? row?.Percent;
    if (fromRow != null && String(fromRow).trim() !== '') return String(fromRow).trim();
    return cfgStr(cfg, [`${key}Percent`, `${key}DailyValue`, `${key}DailyValuePercent`], '');
  }
  if (field === 'unit') {
    const fromRow = row?.unit;
    if (fromRow != null && String(fromRow).trim() !== '') return String(fromRow).trim();
    const def = NUTRITION_FACTS_LAYOUT_ROWS.find((r) => r.key === key);
    return cfgStr(cfg, [`${key}Unit`], def?.defaultUnit ?? '');
  }
  const fromRow = row?.value;
  if (fromRow != null && String(fromRow).trim() !== '') return String(fromRow).trim();
  return cfgStr(cfg, [key, key.charAt(0).toUpperCase() + key.slice(1)], '');
}

/** 格式化含量:仅 optional "<" 前缀;单位由录入时在 amount 中自行填写,不自动拼接 */
export function formatNutritionAmount(
  value: string,
  _unit?: string,
  lessThan?: boolean,
): string {
  const v = String(value ?? '').trim();
  if (!v) return '';
  const prefix = lessThan ? '<' : '';
  return `${prefix}${v}`;
}

export function formatNutritionDailyValue(raw: string): string {
  const v = String(raw ?? '').trim();
  if (!v) return '';
  return v.endsWith('%') ? v : `${v}%`;
}

function nutritionExtraRows(cfg: Record<string, unknown>): Array<{
  id: string;
  name: string;
  value: string;
  unit: string;
}> {
  const raw = cfg.extraNutrients;
  if (!Array.isArray(raw)) return [];
  return raw.map((item, idx) => {
    const row = item as Record<string, unknown>;
    return {
      id: String(row.id ?? `extra-${idx}`),
      name: String(row.name ?? ''),
      value: String(row.value ?? ''),
      unit: String(row.unit ?? ''),
    };
  });
}

/** 从 config 构建渲染模型(Web 画布 / App canvas 共用逻辑) */
export function buildNutritionFactsViewModel(cfg: Record<string, unknown>): NutritionFactsViewModel {
  const titleFontSize = Number(cfg.nutritionTitleFontSize ?? cfg.NutritionTitleFontSize ?? 16) || 16;
  const servingsValue = cfgStr(cfg, ['servings', 'servingsPerContainer', 'ServingsPerContainer']);
  const servingSizeValue = cfgStr(cfg, ['servingSize', 'ServingSize']);
  const caloriesRaw = nutritionFixedField(cfg, 'calories', 'value') || cfgStr(cfg, ['calories', 'Calories']);
  const caloriesLessThan = readNutritionLessThan(cfg, 'calories');
  const layoutByKey = new Map(NUTRITION_FACTS_LAYOUT_ROWS.map((r) => [r.key, r]));
  const rows: NutritionFactsRowView[] = [];
  const seen = new Set<string>();

  for (const def of NUTRITION_FACTS_LAYOUT_ROWS) {
    seen.add(def.key);
    const value = nutritionFixedField(cfg, def.key, 'value');
    const lessThan = readNutritionLessThan(cfg, def.key);
    const pct = nutritionFixedField(cfg, def.key, 'dailyValuePercent');
    rows.push({
      key: def.key,
      label: String(rowFromFixed(cfg, def.key)?.label ?? def.label),
      amountText: formatNutritionAmount(value, '', lessThan),
      dailyValueText: formatNutritionDailyValue(pct),
      labelBold: def.labelBold ?? false,
      indent: def.indent ?? false,
      dividerAfter: def.dividerAfter ?? 'none',
    });
  }

  for (const ex of nutritionExtraRows(cfg)) {
    const key = `extra:${ex.id}`;
    if (seen.has(key)) continue;
    const lessThan = cfgBool(cfg, [`extra:${ex.id}:lessThan`]);
    rows.push({
      key,
      label: ex.name.trim() || 'Other',
      amountText: formatNutritionAmount(ex.value, '', lessThan),
      dailyValueText: formatNutritionDailyValue(
        cfgStr(cfg, [`extra:${ex.id}:percent`, `extra:${ex.id}:dailyValuePercent`]),
      ),
      labelBold: false,
      indent: false,
      dividerAfter: 'thin',
    });
  }

  for (const fr of fixedRows(cfg)) {
    const key = String(fr.key ?? '').trim();
    if (!key || seen.has(key)) continue;
    const def = layoutByKey.get(key);
    const value = String(fr.value ?? '').trim();
    const lessThan = cfgBool({ lessThan: fr.lessThan }, ['lessThan']);
    rows.push({
      key,
      label: String(fr.label ?? def?.label ?? key),
      amountText: formatNutritionAmount(value, '', lessThan),
      dailyValueText: formatNutritionDailyValue(String(fr.dailyValuePercent ?? fr.percent ?? '')),
      labelBold: def?.labelBold ?? false,
      indent: def?.indent ?? false,
      dividerAfter: def?.dividerAfter ?? 'thin',
    });
  }

  return {
    titleFontSize,
    servingsLabel: cfgStr(cfg, ['servingsLabel'], 'Servings'),
    servingsValue,
    servingSizeLabel: cfgStr(cfg, ['servingSizeLabel'], 'Serve size'),
    servingSizeValue,
    caloriesLabel: cfgStr(cfg, ['caloriesLabel'], 'Calories'),
    caloriesValue: caloriesRaw,
    caloriesAmountText: formatNutritionAmount(caloriesRaw, '', caloriesLessThan),
    rows,
    footerNote: cfgStr(cfg, ['nutritionFooterNote', 'footerNote'], DEFAULT_NUTRITION_FOOTER_NOTE),
    ingredientsText: cfgStr(cfg, ['ingredientsText', 'ingredients', 'IngredientsText']),
  };
}