码桶

发现社区成员的开源项目

main
NodeNanny/core/star-layout.js
star-layout.js9.2 KB
'use strict';

// 守望星图·布局算法(批次五·星图第一步)。
//
// 定位:纯计算函数,只负责"给定一批节点/来源,算出每颗星在画布上的坐标",
// 不掺杂任何DOM/SVG/HTML渲染逻辑——方便单独写自动化测试,也方便以后
// 面板正式接入时直接复用这份计算结果去画图。
//
// 设计依据(创始人已拍板的三个决策,详见交接文档三十六.6节):
// 1. 数量上限:最多摆20颗"来源星"(established/trial),超过按weight
//    从高到低取前20,截掉的不在这个函数的职责范围内(由调用方在传入
//    nodes之前先做好截断)。
// 2. 拉黑来源(blacklisted):固定摆在画布角落一小簇,不参与主星区的
//    随机布局,数量本身也有软上限(默认8个,更多的话调用方自己在
//    UI上收纳成"+N",这个函数只负责给到"最多能摆几个坐标")。
// 3. 布局随机性:每次调用都重新随机(不做sourceId哈希固定位置)。
//
// 算法:拒绝采样(rejection sampling),不引入力导向/物理引擎这类
// 重量级方案,符合项目"极简优先"的原则。

const MAX_MAIN_STARS = 20; // 主星区(established+trial)最多同时布局的星数
const MAX_BLACKLIST_STARS = 8; // 角落拉黑区最多同时布局的星数
const MIN_DISTANCE = 18; // 星与星之间的最小间距(像素)
const MAX_RETRIES = 20; // 单颗星找不重叠位置的最大重试次数
const JITTER = 2; // 最终坐标叠加的小幅随机抖动(像素,正负范围)

function distance(a, b) {
  const dx = a.x - b.x;
  const dy = a.y - b.y;
  return Math.sqrt(dx * dx + dy * dy);
}

function randomInRange(min, max) {
  return min + Math.random() * (max - min);
}

// 在给定矩形区域内,用拒绝采样摆放count个点,尽量互相不重叠。
// area: { xMin, xMax, yMin, yMax }
// existingPoints: 已经放好的点(比如另一个分区已经占用的坐标),新点也要避开这些。
// 返回:坐标数组,长度等于count(即使最后没能完全避开重叠,也不会少放)。
function placePointsInArea(count, area, existingPoints) {
  const placed = [];
  const allPoints = existingPoints.slice(); // 避开这个分区之外已放置的点

  for (let i = 0; i < count; i++) {
    let candidate = null;
    let attempts = 0;
    while (attempts < MAX_RETRIES) {
      const x = randomInRange(area.xMin, area.xMax);
      const y = randomInRange(area.yMin, area.yMax);
      const tooClose = allPoints.some((p) => distance(p, { x, y }) < MIN_DISTANCE);
      if (!tooClose) {
        candidate = { x, y };
        break;
      }
      candidate = { x, y }; // 即使这次太近,也先记下来,万一是最后一次尝试就用它
      attempts++;
    }
    // 叠加小幅抖动,避免"网格感"(即便是拒绝采样已经算出的坐标,也不完全是规整分布,
    // 但抖动能让最终视觉效果更自然一些,不会看起来像是精确计算出来的)。
    const jittered = {
      x: candidate.x + randomInRange(-JITTER, JITTER),
      y: candidate.y + randomInRange(-JITTER, JITTER)
    };
    // 抖动之后夹回画布边界内,避免星星画到区域外面去。
    jittered.x = Math.min(Math.max(jittered.x, area.xMin), area.xMax);
    jittered.y = Math.min(Math.max(jittered.y, area.yMin), area.yMax);

    placed.push(jittered);
    allPoints.push(jittered);
  }

  return placed;
}

// 主入口。
// nodes: 数组,每个元素形如 { sourceId, status, weight }
//        status: 'established' | 'trial' | 'blacklisted'
//        weight: 数字,越大越优先(不足20个建立/试用来源时无需截断,调用方也可以
//                提前截好再传进来,这个函数本身也会做一次兜底截断,双重保险)。
// canvasWidth/canvasHeight: 画布尺寸(像素),用于圈定可摆放区域。
// harborArea: 可选,{ xMin, xMax, yMin, yMax } 形式,标出"港口标记"(主节点图标)
//             占用的区域,布局时会避开这块地方,不传的话默认不做特殊排除。
//
// 返回:{ stars: [{ sourceId, x, y, status }], overflowCount, blacklistOverflowCount }
//   overflowCount: 主星区因为超过MAX_MAIN_STARS上限而没有画出来的established/trial来源数量
//   blacklistOverflowCount: 拉黑区因为超过MAX_BLACKLIST_STARS上限而没有画出来的数量
// 在一个椭圆放射区域内(以centerX/centerY为锚点,radiusX/radiusY为半径)按
// [minFrac, maxFrac]这个半径比例区间随机取点——用"放射状环带"代替"同心矩形",
// 能自然铺满整个画布,不会把established/trial挤在中间一小块矩形里。
function placePointsRadial(count, opts, existingPoints) {
  const { centerX, centerY, radiusX, radiusY, minFrac, maxFrac, xMin, xMax, yMin, yMax } = opts;
  const placed = [];
  const allPoints = existingPoints.slice();

  for (let i = 0; i < count; i++) {
    let candidate = null;
    let attempts = 0;
    while (attempts < MAX_RETRIES) {
      const angle = randomInRange(0, Math.PI * 2);
      const frac = randomInRange(minFrac, maxFrac);
      let x = centerX + Math.cos(angle) * radiusX * frac;
      let y = centerY + Math.sin(angle) * radiusY * frac;
      x = Math.min(Math.max(x, xMin), xMax);
      y = Math.min(Math.max(y, yMin), yMax);
      const tooClose = allPoints.some((p) => distance(p, { x, y }) < MIN_DISTANCE);
      if (!tooClose) { candidate = { x, y }; break; }
      candidate = { x, y };
      attempts++;
    }
    const jittered = {
      x: Math.min(Math.max(candidate.x + randomInRange(-JITTER, JITTER), xMin), xMax),
      y: Math.min(Math.max(candidate.y + randomInRange(-JITTER, JITTER), yMin), yMax)
    };
    placed.push(jittered);
    allPoints.push(jittered);
  }
  return placed;
}

function layoutStars(nodes, canvasWidth, canvasHeight, harborArea) {
  const list = Array.isArray(nodes) ? nodes : [];

  const mainCandidates = list
    .filter((n) => n && (n.status === 'established' || n.status === 'trial'))
    .slice() // 不修改调用方传进来的原数组
    .sort((a, b) => (b.weight || 0) - (a.weight || 0));

  const blacklistCandidates = list.filter((n) => n && n.status === 'blacklisted');

  const mainToPlace = mainCandidates.slice(0, MAX_MAIN_STARS);
  const overflowCount = Math.max(mainCandidates.length - MAX_MAIN_STARS, 0);

  const blacklistToPlace = blacklistCandidates.slice(0, MAX_BLACKLIST_STARS);
  const blacklistOverflowCount = Math.max(blacklistCandidates.length - MAX_BLACKLIST_STARS, 0);

  // 本轮修复(创始人反馈第一版demo挤成一团后):放弃"两个同心矩形"这个思路,
  // 改用"以锚点为中心的放射状环带"——established取内环半径比例区间,trial取
  // 外环区间,但两者的半径比例区间都覆盖到接近整个画布,不会被限制在一小块
  // 矩形里,视觉上更接近"满天星"的疏朗感。
  const margin = Math.min(canvasWidth, canvasHeight) * 0.06;
  const usableArea = { xMin: margin, xMax: canvasWidth - margin, yMin: margin, yMax: canvasHeight - margin };
  // 锚点特意不放在画布正中心,而是偏右上一些——呼应港口(主节点)固定在左下角,
  // 让整体构图有"从左下港口望向右上星空"的方向感,不是死板的居中对称。
  const centerX = canvasWidth * 0.56;
  const centerY = canvasHeight * 0.42;
  const radiusX = canvasWidth / 2 - margin;
  const radiusY = canvasHeight / 2 - margin;

  const preOccupied = [];
  if (harborArea) {
    preOccupied.push({
      x: (harborArea.xMin + harborArea.xMax) / 2,
      y: (harborArea.yMin + harborArea.yMax) / 2
    });
  }

  const established = mainToPlace.filter((n) => n.status === 'established');
  const trial = mainToPlace.filter((n) => n.status === 'trial');

  const establishedPoints = placePointsRadial(established.length, {
    centerX, centerY, radiusX, radiusY, minFrac: 0.08, maxFrac: 0.62, ...usableArea
  }, preOccupied);
  const trialPoints = placePointsRadial(trial.length, {
    centerX, centerY, radiusX, radiusY, minFrac: 0.38, maxFrac: 0.98, ...usableArea
  }, preOccupied.concat(establishedPoints));

  const mainStars = [];
  established.forEach((n, i) => {
    mainStars.push({ sourceId: n.sourceId, status: n.status, weight: n.weight, x: establishedPoints[i].x, y: establishedPoints[i].y });
  });
  trial.forEach((n, i) => {
    mainStars.push({ sourceId: n.sourceId, status: n.status, weight: n.weight, x: trialPoints[i].x, y: trialPoints[i].y });
  });

  // 拉黑角落区:固定在画布右下角一小块(独立区域,间距要求也放宽一些,
  // 因为这里本来就是"熄灭的星",紧凑一点反而符合"角落一小簇"的设计意图)。
  const cornerSize = Math.min(canvasWidth, canvasHeight) * 0.2;
  const cornerArea = {
    xMin: canvasWidth - cornerSize - margin * 0.6,
    xMax: canvasWidth - margin * 0.6,
    yMin: canvasHeight - cornerSize - margin * 0.6,
    yMax: canvasHeight - margin * 0.6
  };
  const blacklistPoints = placePointsInArea(blacklistToPlace.length, cornerArea, []);
  const blacklistStars = blacklistToPlace.map((n, i) => ({
    sourceId: n.sourceId, status: n.status, weight: n.weight,
    x: blacklistPoints[i].x, y: blacklistPoints[i].y
  }));

  return {
    stars: mainStars.concat(blacklistStars),
    overflowCount,
    blacklistOverflowCount
  };
}

module.exports = {
  layoutStars,
  MAX_MAIN_STARS,
  MAX_BLACKLIST_STARS,
  MIN_DISTANCE
};