update at 2026-02-13 23:00:20

This commit is contained in:
douboer@gmail.com
2026-02-13 23:00:20 +08:00
parent 1ef046719c
commit f5f6a42bfa
3 changed files with 188 additions and 40 deletions

View File

@@ -174,6 +174,10 @@ const DEFAULT_THEME_INDEX = (() => {
const index = themePresets.findIndex((item) => item && item.id === DEFAULT_THEME_ID); const index = themePresets.findIndex((item) => item && item.id === DEFAULT_THEME_ID);
return index >= 0 ? index : 0; return index >= 0 ? index : 0;
})(); })();
const THEME_ROW_HEIGHT_PX = 36;
const THEME_VISIBLE_ROW_COUNT = 6;
const THEME_LIST_HEIGHT_PX = THEME_ROW_HEIGHT_PX * THEME_VISIBLE_ROW_COUNT;
const THEME_LIST_EDGE_SPACER_PX = (THEME_LIST_HEIGHT_PX - THEME_ROW_HEIGHT_PX) / 2;
/** /**
* 数值限制,避免 UI 参数导致布局异常。 * 数值限制,避免 UI 参数导致布局异常。
@@ -186,6 +190,19 @@ function clampNumber(value, min, max, fallback) {
return Math.min(max, Math.max(min, normalized)); return Math.min(max, Math.max(min, normalized));
} }
/**
* 主题弹层打开时的滚动定位:
* 通过上下占位留白,让任意索引都可滚动到视图中心。
*/
function getThemeListScrollTopByIndex(index, totalCount) {
const total = Number(totalCount);
if (!Number.isFinite(total) || total <= 0) {
return 0;
}
const safeIndex = clampNumber(index, 0, total - 1, 0);
return safeIndex * THEME_ROW_HEIGHT_PX;
}
/** /**
* 统一错误文案: * 统一错误文案:
* - xlsx 解析能力缺失时,固定提示用户去“构建 npm” * - xlsx 解析能力缺失时,固定提示用户去“构建 npm”
@@ -286,17 +303,23 @@ function buildSankeyLayout(links, width, height, renderOptions) {
const sourceValueMap = {}; const sourceValueMap = {};
const targetValueMap = {}; const targetValueMap = {};
const nodeColorIndexMap = {};
let nodeColorIndexCursor = 0;
links.forEach((link) => { links.forEach((link) => {
sourceValueMap[link.source] = (sourceValueMap[link.source] || 0) + Number(link.value || 0); sourceValueMap[link.source] = (sourceValueMap[link.source] || 0) + Number(link.value || 0);
targetValueMap[link.target] = (targetValueMap[link.target] || 0) + Number(link.value || 0); targetValueMap[link.target] = (targetValueMap[link.target] || 0) + Number(link.value || 0);
if (!Object.prototype.hasOwnProperty.call(nodeColorIndexMap, link.source)) {
nodeColorIndexMap[link.source] = nodeColorIndexCursor;
nodeColorIndexCursor += 1;
}
if (!Object.prototype.hasOwnProperty.call(nodeColorIndexMap, link.target)) {
nodeColorIndexMap[link.target] = nodeColorIndexCursor;
nodeColorIndexCursor += 1;
}
}); });
const sourceNames = Object.keys(sourceValueMap); const sourceNames = Object.keys(sourceValueMap);
const targetNames = Object.keys(targetValueMap); const targetNames = Object.keys(targetValueMap);
const sourceIndexMap = {};
sourceNames.forEach((name, index) => {
sourceIndexMap[name] = index;
});
const totalValue = sourceNames.reduce((sum, name) => sum + sourceValueMap[name], 0); const totalValue = sourceNames.reduce((sum, name) => sum + sourceValueMap[name], 0);
if (totalValue <= 0) { if (totalValue <= 0) {
return null; return null;
@@ -371,7 +394,7 @@ function buildSankeyLayout(links, width, height, renderOptions) {
sy, sy,
ty, ty,
linkHeight, linkHeight,
sourceIndex: Number.isFinite(sourceIndexMap[link.source]) ? sourceIndexMap[link.source] : 0 sourceIndex: Number.isFinite(nodeColorIndexMap[link.source]) ? nodeColorIndexMap[link.source] : 0
}); });
}); });
@@ -383,6 +406,7 @@ function buildSankeyLayout(links, width, height, renderOptions) {
targetNames, targetNames,
sourcePos, sourcePos,
targetPos, targetPos,
nodeColorIndexMap,
linkSegments linkSegments
}; };
} }
@@ -431,12 +455,15 @@ function buildSankeySvgText(links, width, height, renderOptions) {
layout.sourceNames.forEach((name, index) => { layout.sourceNames.forEach((name, index) => {
const node = layout.sourcePos[name]; const node = layout.sourcePos[name];
const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name])
? layout.nodeColorIndexMap[name]
: index;
const textY = node.y + getNodeLabelCenterY(node.h); const textY = node.y + getNodeLabelCenterY(node.h);
const textPlacement = getSvgLabelPlacement(layout, true, labelPositionMode); const textPlacement = getSvgLabelPlacement(layout, true, labelPositionMode);
segments.push( segments.push(
`<rect x="${formatSvgNumber(layout.leftX)}" y="${formatSvgNumber(node.y)}" width="${formatSvgNumber( `<rect x="${formatSvgNumber(layout.leftX)}" y="${formatSvgNumber(node.y)}" width="${formatSvgNumber(
layout.nodeWidth layout.nodeWidth
)}" height="${formatSvgNumber(node.h)}" fill="${getNodeColor(index, themeColors)}" />` )}" height="${formatSvgNumber(node.h)}" fill="${getNodeColor(colorIndex, themeColors)}" />`
); );
segments.push( segments.push(
`<text x="${formatSvgNumber(textPlacement.x)}" y="${formatSvgNumber(textY)}" fill="#4e5969" font-size="10" text-anchor="${ `<text x="${formatSvgNumber(textPlacement.x)}" y="${formatSvgNumber(textY)}" fill="#4e5969" font-size="10" text-anchor="${
@@ -447,12 +474,15 @@ function buildSankeySvgText(links, width, height, renderOptions) {
layout.targetNames.forEach((name, index) => { layout.targetNames.forEach((name, index) => {
const node = layout.targetPos[name]; const node = layout.targetPos[name];
const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name])
? layout.nodeColorIndexMap[name]
: index;
const textY = node.y + getNodeLabelCenterY(node.h); const textY = node.y + getNodeLabelCenterY(node.h);
const textPlacement = getSvgLabelPlacement(layout, false, labelPositionMode); const textPlacement = getSvgLabelPlacement(layout, false, labelPositionMode);
segments.push( segments.push(
`<rect x="${formatSvgNumber(layout.rightX)}" y="${formatSvgNumber(node.y)}" width="${formatSvgNumber( `<rect x="${formatSvgNumber(layout.rightX)}" y="${formatSvgNumber(node.y)}" width="${formatSvgNumber(
layout.nodeWidth layout.nodeWidth
)}" height="${formatSvgNumber(node.h)}" fill="${getNodeColor(index, themeColors)}" />` )}" height="${formatSvgNumber(node.h)}" fill="${getNodeColor(colorIndex, themeColors)}" />`
); );
segments.push( segments.push(
`<text x="${formatSvgNumber(textPlacement.x)}" y="${formatSvgNumber(textY)}" fill="#4e5969" font-size="10" text-anchor="${ `<text x="${formatSvgNumber(textPlacement.x)}" y="${formatSvgNumber(textY)}" fill="#4e5969" font-size="10" text-anchor="${
@@ -470,6 +500,10 @@ Page({
selectedThemeIndex: DEFAULT_THEME_INDEX, selectedThemeIndex: DEFAULT_THEME_INDEX,
themes: Array.isArray(themePresets) ? themePresets : [], themes: Array.isArray(themePresets) ? themePresets : [],
showThemeSheet: false, showThemeSheet: false,
themeListScrollTop: getThemeListScrollTopByIndex(
DEFAULT_THEME_INDEX,
Array.isArray(themePresets) ? themePresets.length : 0
),
uploadMessage: '默认加载 data/sankey.xlsx 中...', uploadMessage: '默认加载 data/sankey.xlsx 中...',
parseError: '', parseError: '',
buildError: '', buildError: '',
@@ -478,6 +512,9 @@ Page({
sourceDataColumn: null, sourceDataColumn: null,
sourceDescriptionColumns: [], sourceDescriptionColumns: [],
targetDescriptionColumns: [], targetDescriptionColumns: [],
sectionVisibleSourceData: true,
sectionVisibleSourceDesc: true,
sectionVisibleTargetDesc: true,
nodesCount: 0, nodesCount: 0,
linksCount: 0, linksCount: 0,
droppedRows: 0, droppedRows: 0,
@@ -567,7 +604,32 @@ Page({
* 主题选择按钮点击后,切换底部选择器。 * 主题选择按钮点击后,切换底部选择器。
*/ */
onToggleThemeSheet() { onToggleThemeSheet() {
this.setData({ showThemeSheet: !this.data.showThemeSheet }); const nextVisible = !this.data.showThemeSheet;
if (!nextVisible) {
this.setData({ showThemeSheet: false });
return;
}
const targetScrollTop = getThemeListScrollTopByIndex(
this.data.selectedThemeIndex,
(this.data.themes || []).length
);
this.setData(
{
showThemeSheet: true,
themeListScrollTop: 0
},
() => {
const applyScrollTop = () => {
this.setData({ themeListScrollTop: targetScrollTop });
};
if (typeof wx.nextTick === 'function') {
wx.nextTick(applyScrollTop);
return;
}
setTimeout(applyScrollTop, 0);
}
);
}, },
/** /**
@@ -577,6 +639,24 @@ Page({
this.setData({ showThemeSheet: false }); this.setData({ showThemeSheet: false });
}, },
/**
* 切换数据映射区块展开/收起状态,对齐 Web 的 expand/zhedie 行为。
*/
onToggleSection(e) {
const section = String((e.currentTarget.dataset && e.currentTarget.dataset.section) || '');
if (section === 'sourceData') {
this.setData({ sectionVisibleSourceData: !this.data.sectionVisibleSourceData });
return;
}
if (section === 'sourceDesc') {
this.setData({ sectionVisibleSourceDesc: !this.data.sectionVisibleSourceDesc });
return;
}
if (section === 'targetDesc') {
this.setData({ sectionVisibleTargetDesc: !this.data.sectionVisibleTargetDesc });
}
},
/** /**
* 返回当前选中主题色带。 * 返回当前选中主题色带。
*/ */
@@ -1063,7 +1143,10 @@ Page({
layout.sourceNames.forEach((name, index) => { layout.sourceNames.forEach((name, index) => {
const node = layout.sourcePos[name]; const node = layout.sourcePos[name];
const labelPlacement = getCanvasLabelPlacement(layout, true, this.data.labelPositionMode); const labelPlacement = getCanvasLabelPlacement(layout, true, this.data.labelPositionMode);
ctx.setFillStyle(getNodeColor(index, themeColors)); const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name])
? layout.nodeColorIndexMap[name]
: index;
ctx.setFillStyle(getNodeColor(colorIndex, themeColors));
ctx.fillRect(layout.leftX, node.y, layout.nodeWidth, node.h); ctx.fillRect(layout.leftX, node.y, layout.nodeWidth, node.h);
ctx.setFillStyle('#4e5969'); ctx.setFillStyle('#4e5969');
ctx.setFontSize(10); ctx.setFontSize(10);
@@ -1075,7 +1158,10 @@ Page({
layout.targetNames.forEach((name, index) => { layout.targetNames.forEach((name, index) => {
const node = layout.targetPos[name]; const node = layout.targetPos[name];
const labelPlacement = getCanvasLabelPlacement(layout, false, this.data.labelPositionMode); const labelPlacement = getCanvasLabelPlacement(layout, false, this.data.labelPositionMode);
ctx.setFillStyle(getNodeColor(index, themeColors)); const colorIndex = Number.isFinite(layout.nodeColorIndexMap[name])
? layout.nodeColorIndexMap[name]
: index;
ctx.setFillStyle(getNodeColor(colorIndex, themeColors));
ctx.fillRect(layout.rightX, node.y, layout.nodeWidth, node.h); ctx.fillRect(layout.rightX, node.y, layout.nodeWidth, node.h);
ctx.setFillStyle('#4e5969'); ctx.setFillStyle('#4e5969');
ctx.setFontSize(10); ctx.setFontSize(10);

View File

@@ -97,11 +97,17 @@
<text wx:if="{{columnHeaders.length === 0}}" class="empty-tip">未加载文件,暂无列信息</text> <text wx:if="{{columnHeaders.length === 0}}" class="empty-tip">未加载文件,暂无列信息</text>
<view class="field-group"> <view class="field-group">
<view class="field-title"> <view class="field-title" data-section="sourceData" bindtap="onToggleSection">
<image src="../../assets/icons/expand.svg" mode="aspectFit" /> <image
src="{{sectionVisibleSourceData ? '../../assets/icons/zhedie.svg' : '../../assets/icons/expand.svg'}}"
mode="aspectFit"
/>
<text>源数据(link value)</text> <text>源数据(link value)</text>
</view> </view>
<view class="column-list" wx:if="{{sectionVisibleSourceData}}">
<view class="column-list-line" />
<view class="row" wx:for="{{columnHeaders}}" wx:key="*this" data-index="{{index}}" bindtap="onSelectSourceData"> <view class="row" wx:for="{{columnHeaders}}" wx:key="*this" data-index="{{index}}" bindtap="onSelectSourceData">
<view class="row-link" />
<image src="../../assets/icons/data.svg" mode="aspectFit" /> <image src="../../assets/icons/data.svg" mode="aspectFit" />
<text class="label">{{item}}</text> <text class="label">{{item}}</text>
<image <image
@@ -110,13 +116,20 @@
/> />
</view> </view>
</view> </view>
</view>
<view class="field-group"> <view class="field-group">
<view class="field-title"> <view class="field-title" data-section="sourceDesc" bindtap="onToggleSection">
<image src="../../assets/icons/expand.svg" mode="aspectFit" /> <image
src="{{sectionVisibleSourceDesc ? '../../assets/icons/zhedie.svg' : '../../assets/icons/expand.svg'}}"
mode="aspectFit"
/>
<text>源标签(Source label)</text> <text>源标签(Source label)</text>
</view> </view>
<view class="column-list" wx:if="{{sectionVisibleSourceDesc}}">
<view class="column-list-line" />
<view class="row" wx:for="{{columnHeaders}}" wx:key="*this" data-index="{{index}}" bindtap="onToggleSourceDesc"> <view class="row" wx:for="{{columnHeaders}}" wx:key="*this" data-index="{{index}}" bindtap="onToggleSourceDesc">
<view class="row-link" />
<image src="../../assets/icons/description.svg" mode="aspectFit" /> <image src="../../assets/icons/description.svg" mode="aspectFit" />
<text class="label">{{item}}</text> <text class="label">{{item}}</text>
<image <image
@@ -125,13 +138,20 @@
/> />
</view> </view>
</view> </view>
</view>
<view class="field-group"> <view class="field-group">
<view class="field-title"> <view class="field-title" data-section="targetDesc" bindtap="onToggleSection">
<image src="../../assets/icons/expand.svg" mode="aspectFit" /> <image
src="{{sectionVisibleTargetDesc ? '../../assets/icons/zhedie.svg' : '../../assets/icons/expand.svg'}}"
mode="aspectFit"
/>
<text>目标标签(target label)</text> <text>目标标签(target label)</text>
</view> </view>
<view class="column-list" wx:if="{{sectionVisibleTargetDesc}}">
<view class="column-list-line" />
<view class="row" wx:for="{{columnHeaders}}" wx:key="*this" data-index="{{index}}" bindtap="onToggleTargetDesc"> <view class="row" wx:for="{{columnHeaders}}" wx:key="*this" data-index="{{index}}" bindtap="onToggleTargetDesc">
<view class="row-link" />
<image src="../../assets/icons/description.svg" mode="aspectFit" /> <image src="../../assets/icons/description.svg" mode="aspectFit" />
<text class="label">{{item}}</text> <text class="label">{{item}}</text>
<image <image
@@ -140,6 +160,7 @@
/> />
</view> </view>
</view> </view>
</view>
</scroll-view> </scroll-view>
</view> </view>
@@ -156,8 +177,17 @@
<view class="theme-sheet-mask" wx:if="{{showThemeSheet}}" bindtap="onCloseThemeSheet" /> <view class="theme-sheet-mask" wx:if="{{showThemeSheet}}" bindtap="onCloseThemeSheet" />
<view class="theme-sheet" wx:if="{{showThemeSheet}}"> <view class="theme-sheet" wx:if="{{showThemeSheet}}">
<text class="theme-title">选择配色主题</text> <text class="theme-title">选择配色主题</text>
<scroll-view class="theme-list" scroll-y enhanced show-scrollbar="true"> <scroll-view
class="theme-list"
scroll-y
enhanced
show-scrollbar="true"
scroll-top="{{themeListScrollTop}}"
scroll-with-animation
>
<view class="theme-list-spacer" />
<view <view
id="theme-row-{{themeIndex}}"
class="theme-row" class="theme-row"
wx:for="{{themes}}" wx:for="{{themes}}"
wx:key="id" wx:key="id"
@@ -180,6 +210,7 @@
/> />
</view> </view>
</view> </view>
<view class="theme-list-spacer" />
</scroll-view> </scroll-view>
</view> </view>
</view> </view>

View File

@@ -315,6 +315,7 @@
color: #1d2129; color: #1d2129;
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
padding: 2px 0;
} }
.field-title image { .field-title image {
@@ -322,16 +323,42 @@
height: 14px; height: 14px;
} }
.column-list {
position: relative;
display: flex;
flex-direction: column;
gap: 4px;
}
.column-list-line {
position: absolute;
left: 8px;
top: 0;
bottom: 14px;
width: 1px;
background: #c9cdd4;
}
.row { .row {
height: 24px; position: relative;
border-bottom: 1px solid #c9cdd4; min-height: 24px;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding-left: 20px;
padding-bottom: 8px; padding-bottom: 8px;
box-sizing: border-box; box-sizing: border-box;
} }
.row-link {
position: absolute;
left: 8px;
top: 9px;
width: 10px;
height: 1px;
background: #c9cdd4;
}
.row image { .row image {
width: 18px; width: 18px;
height: 18px; height: 18px;
@@ -385,7 +412,7 @@
bottom: 0; bottom: 0;
background: #fff; background: #fff;
border-radius: 16px 16px 0 0; border-radius: 16px 16px 0 0;
padding: 12px 12px 16px; padding: 12px 32px 32px;
z-index: 11; z-index: 11;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -402,6 +429,10 @@
min-height: 216px; min-height: 216px;
} }
.theme-list-spacer {
height: 90px;
}
.theme-row { .theme-row {
display: flex; display: flex;
align-items: center; align-items: center;