Skip to content

Instantly share code, notes, and snippets.

@no-today
Last active September 18, 2024 13:11
Show Gist options
  • Save no-today/60bae910f3cee67e403a4af79ad72a9e to your computer and use it in GitHub Desktop.
Save no-today/60bae910f3cee67e403a4af79ad72a9e to your computer and use it in GitHub Desktop.
Sankey diagram & Swift UI
<!-- https://observablehq.com/d/4c6e7c61c31e12f2 -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://d3js.org/d3.v7.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/d3-sankey.min.js"></script>
<style>
html body {
margin: 0;
padding: 0;
height: 100%;
}
</style>
</head>
<body>
<!--<div id="log"></div>-->
<div id="sankey"></div>
<script>
function drawChart(config, nodes, links) {
drawSankeyDiagram(config, preprocessingData(nodes, links))
}
function preprocessingData(nodes, links) {
const mapping = nodes.reduce((acc, item) => {
acc.set(item.name, item)
return acc;
}, new Map());
// 总资产
const totalAssets = links.filter(e => mapping.get(e.source).tag === 1)
.reduce((acc, cur) => acc + cur.value, 0)
// 总负债
const totalDebt = links.filter(e => mapping.get(e.source).tag === 3)
.reduce((acc, cur) => acc + cur.value, 0);
// 总资产节点名称
const assetsNodeName = nodes.filter(e => e.tag === 1)[0].name
// 净资产节点名称
const netAssetsNodeName = nodes.filter(e => e.tag === 2)[0].name
links.push({source: netAssetsNodeName, target: assetsNodeName, value: totalAssets - totalDebt})
// 每个节点所占百分比,都是以总资产为基准
const targetTotals = {};
links.forEach(link => {
if (!targetTotals[link.target]) {
targetTotals[link.target] = 0;
}
targetTotals[link.target] += link.value;
});
links.forEach(link => {
const key = link.target === assetsNodeName ? link.source : link.target;
const entry = mapping.get(key);
if (entry) {
entry.percentage = (link.value / targetTotals[assetsNodeName] * 100).toFixed(2);
} else {
console.error(`No mapping found for key: ${key}`);
}
});
return {nodes, links, totalAssets, totalDebt};
}
/**
* 格式化金额
* @param {number} value - 数值
* @param {boolean} isPositive - 是否为正数
* @param {string} mode - 展示模式
* @returns {string} - 格式化后的金额字符串
*/
function formatAmount(value, isPositive, percentage, mode) {
if (mode === 0 || mode === 3) {
return "";
}
if (mode === 2) {
return `${percentage}%`;
}
// 判断符号,根据 isPositive 参数决定是正号还是负号
let sign = isPositive ? '' : '-';
// 判断数值是否大于等于 10000
if (value >= 10000) {
// 将数值除以 10000 转换为以 '万' 为单位的数值
let formattedValue = (value / 10000).toFixed(2); // 保留两位小数
// 使用正则表达式去掉末尾多余的 '.00' 或 '0'
formattedValue = formattedValue.replace(/\\.00$|0$/, '');
// 返回带符号和 '万' 单位的格式化字符串
return `${sign}${formattedValue}万`;
} else {
// 数值小于 10000,直接展示
return `${sign}${value}`;
}
}
// https://github.com/observablehq/stdlib/blob/main/src/dom/uid.js
// DOM.uid() can only be used in an observable environment
var count = 0;
function uid(name) {
return new Id("O-" + (name == null ? "" : name + "-") + ++count);
}
function Id(id) {
this.id = id;
this.href = new URL(`#${id}`, location) + "";
}
Id.prototype.toString = function () {
return "url(" + this.href + ")";
};
function drawSankeyDiagram(config, data) {
// Create a SVG container.
const svg = d3.select('#sankey').append("svg")
.attr("viewBox", [0, 0, config.width, config.height])
.attr("style", `max-width: 100%; height: auto; font: ${config.fontSize}px sans-serif;`);
// Constructs and configures a Sankey generator.
// .extent([[x0, y0], [x1, y1]])
const sankey = d3.sankey()
.nodeId(d => d.name)
.nodeAlign(d3[config.nodeAlign]) // d3.sankeyLeft, etc.
.nodeWidth(config.nodeWidth)
.nodePadding(config.nodePadding)
.extent([[config.extents[0], config.extents[1]], [config.width - config.extents[2], config.height - config.extents[3]]]);
// Applies it to the data. We make a copy of the nodes and links objects
// so as to avoid mutating the original.
const {nodes, links} = sankey({
nodes: data.nodes.map(d => Object.assign({}, d)),
links: data.links.map(d => Object.assign({}, d))
});
// 总资产 (目标节点
const totalAssetsNode = nodes.filter(e => e.tag === 1)[0]
// 净资产 (起始节点
const netAssetsNode = nodes.filter(e => e.tag === 2)[0]
// Creates the rects that represent the nodes.
const rect = svg.append("g")
.attr("stroke", 'transparent')
.selectAll()
.data(nodes)
.join("rect")
.attr("x", d => d.tag === 1 ? d.x0 - config.boldWidth - config.nodeWidth - config.dividerWidth : d.x0)
.attr("y", d => d.y0)
.attr("height", d => d.y1 - d.y0 - (d.tag === 1 ? netAssetsNode.y1 - netAssetsNode.y0 : 0)) // 净资产不是负债的一部分
.attr("width", d => d.x1 - d.x0 + (d.tag === 1 ? config.boldWidth : 0))
.attr("fill", d => d.color);
// 补偿总资产矩形
svg.append("rect")
.attr("x", totalAssetsNode.x0 + config.nodeWidth) // 矩形的 x 坐标
.attr("y", totalAssetsNode.y0) // 矩形的 y 坐标
.attr("width", config.nodeWidth + config.boldWidth) // 矩形的宽度
.attr("height", totalAssetsNode.y1 - totalAssetsNode.y0) // 矩形的高度
.attr("fill", config.totalAssetsRectColor ? config.totalAssetsRectColor : config.totalAssetsTextColor) // 矩形的填充颜色
// Creates the paths that represent the links.
const link = svg.append("g")
.attr("fill", "none")
.attr("stroke-opacity", 0.5)
.selectAll()
.data(links)
.join("g")
.style("mix-blend-mode", "multiply");
// Creates a gradient, if necessary, for the source-target color option.
if (config.linkColor === "source-target") {
const gradient = link.append("linearGradient")
.attr("id", d => (d.uid = uid("link")).id)
.attr("gradientUnits", "userSpaceOnUse")
.attr("x1", d => d.source.x1)
.attr("x2", d => d.target.x0);
gradient.append("stop")
.attr("offset", "0%")
.attr("stop-color", d => d.source.color);
gradient.append("stop")
.attr("offset", "100%")
.attr("stop-color", d => d.target.color);
}
link.append("path")
.attr("d", d => {
// 总资产微调
if (d.target.tag === 1) {
// 获取默认的路径字符串
const defaultPath = d3.sankeyLinkHorizontal()(d);
// 解析路径字符串
const pathCommands = defaultPath.split(/(?=[A-Z])/); // 按大写字母分割路径命令
// 对路径进行微调,例如将起始点的 x 坐标平移
const adjustedPathCommands = pathCommands.map(command => {
if (command.startsWith("M")) { // 起始点命令
// const values = command.slice(1).split(",").map(Number);
// values[0] += config.dividerWidth; // 调整起始点的 x 坐标
// return `M${values.join(",")}`;
return command;
} else if (command.startsWith("C")) { // 贝塞尔曲线控制点命令
const values = command.slice(1).split(",").map(Number);
// values[0] += config.dividerWidth; // 调整第一个控制点的 x 坐标
// values[2] += config.dividerWidth; // 调整第二个控制点的 x 坐标
// 保持第一个控制点和起始点不变,调整第二个控制点和终点的 x 坐标
values[2] -= config.dividerWidth; // 调整第二个控制点的 x 坐标,向左移动
values[4] -= config.dividerWidth; // 调整终点的 x 坐标,向左移动
return `C${values.join(",")}`;
}
return command;
});
// 生成调整后的路径字符串
return adjustedPathCommands.join("");
} else {
return d3.sankeyLinkHorizontal()(d);
}
})
.attr("stroke", config.linkColor === "source-target" ? (d) => d.uid
: config.linkColor === "source" ? (d) => d.source.color
: config.linkColor === "target" ? (d) => d.target.color
: config.linkColor)
.attr("stroke-width", d => Math.max(0.5, d.width));
// Adds labels on the nodes.
svg.append("g")
.selectAll()
.data(nodes)
.join("text")
.attr("x", d => config.titleMode === 2 && !d.tag ? d.x1 + config.titlePadding : d.x0 - config.titlePadding)
.attr("y", d => (d.y1 + d.y0) / 2)
.attr("dy", "0.35em")
.attr("text-anchor", d => config.titleMode === 2 && !d.tag ? "start" : "end")
.text(d => d.tag === 1 ? '' : `${d.name} ${formatAmount(d.value, d.tag !== 3, d.percentage, config.displayMode)}`)
// ------------------------------------------------------------------------
const rectHeightCompensate = () => {
return config.displayMode === 1 ? config.assetsRectHeight : config.assetsRectHeight * 0.5
}
const rectWidthCompensate = () => {
return config.displayMode === 1 ? config.assetsRectWidth : config.assetsRectWidth * 0.7
}
const rectTextXCompensate = () => {
return config.displayMode > 1 ? 0 : config.assetsRectTextX
}
// 负债
const totalDebtRect = svg.append("rect")
.attr("x", totalAssetsNode.x0 - config.dividerWidth - rectWidthCompensate()) // 矩形的 x 坐标
.attr("y", totalAssetsNode.y0 - config.assetsRectPadding - rectHeightCompensate()) // 矩形的 y 坐标
.attr("width", rectWidthCompensate()) // 矩形的宽度
.attr("height", rectHeightCompensate()) // 矩形的高度
.attr("fill", "rgba(128, 128, 128, 0.2)") // 矩形的填充颜色
.attr("rx", config.assetsRectCornerRadius) // 矩形的圆角水平半径
.attr("ry", config.assetsRectCornerRadius) // 矩形的圆角垂直半径
.attr('opacity', config.displayMode === 1 ? 1 : 0)
// 矩形内的标题
svg.append("text")
.attr("x", Number(totalDebtRect.attr("x")) + Number(totalDebtRect.attr('width')) - rectTextXCompensate())
.attr("y", Number(totalDebtRect.attr("y")))
.attr("dy", "1.25em") // 调整垂直位置
.attr("text-anchor", "end")
.text(config.totalDebtText)
.attr("fill", config.totalDebtTextColor)
.style("font-weight", "bold")
.style("font-size", config.assetsRectFontSize)
.attr('opacity', config.displayMode !== 0 ? 1 : 0)
// 矩形内的数值
svg.append("text")
.attr("x", Number(totalDebtRect.attr("x")) + Number(totalDebtRect.attr('width')) - rectTextXCompensate()) // 控制文本位置
.attr("y", Number(totalDebtRect.attr("y")))
.attr("dy", "2.75em")
.attr("text-anchor", "end")
.text(`${formatAmount(data.totalDebt, true, null, 1)}`)
.style("font-size", config.fontSize)
.attr('opacity', config.displayMode === 1 ? 1 : 0)
// 总资产
const totalAssetsRect = svg.append("rect")
.attr("x", Number(totalDebtRect.attr("x")) + Number(totalDebtRect.attr('width')) + config.nodeWidth + config.dividerWidth)
.attr("y", totalDebtRect.attr("y"))
.attr("width", rectWidthCompensate())
.attr("height", rectHeightCompensate())
.attr("fill", "rgba(128, 128, 128, 0.2)")
.attr("rx", config.assetsRectCornerRadius)
.attr("ry", config.assetsRectCornerRadius)
.attr('opacity', config.displayMode === 1 ? 1 : 0)
svg.append("text")
.attr("x", Number(totalAssetsRect.attr("x")) + rectTextXCompensate())
.attr("y", Number(totalAssetsRect.attr("y")))
.attr("dy", "1.25em")
.attr("text-anchor", "start")
.text(config.totalAssetsText)
.attr("fill", config.totalAssetsTextColor)
.style("font-weight", "bold")
.style("font-size", config.assetsRectFontSize)
.attr('opacity', config.displayMode !== 0 ? 1 : 0)
svg.append("text")
.attr("x", Number(totalAssetsRect.attr("x")) + rectTextXCompensate())
.attr("y", Number(totalAssetsRect.attr("y")))
.attr("dy", "2.75em")
.attr("text-anchor", "start")
.text(`${formatAmount(data.totalAssets, true, null, 1)}`)
.style("font-size", config.fontSize)
.attr('opacity', config.displayMode === 1 ? 1 : 0)
}
// ------------------------------------------------------------------------------------------
const cfg = {
linkColor: 'source-target',
nodeAlign: 'sankeyJustify',
displayMode: 3,
titleMode: 1,
totalAssetsText: "总资产",
totalDebtText: "负债",
totalAssetsRectColor: "", // 总资产右矩形颜色, 如果为空则复用 totalAssetsTextColor
totalAssetsTextColor: "#8F8DB5",
totalDebtTextColor: "#990000",
width: 700,
height: 200,
fontSize: 10,
nodeWidth: 5,
nodePadding: 13,
titlePadding: 5,
extents: [90, 22, 5, 5],
boldWidth: 7,
dividerWidth: 2,
assetsRectHeight: 33,
assetsRectWidth: 55,
assetsRectPadding: 8,
assetsRectCornerRadius: 5,
assetsRectTextX: 5,
assetsRectTextDy: 1.25,
assetsRectFontSize: 11
}
const nodes = [
// 总资产的颜色反直觉, 实际上总资产分为左右两个颜色了, 这个颜色是左侧的, 右侧的是 config.totalAssetsRectColor
{name: "总资产", color: "#A4A3AB", tag: 1},
{name: "信用卡", color: "#A8A7B0", tag: 3},
{name: "房屋贷款", color: "#A8A7B0", tag: 3},
{name: "车辆贷款", color: "#A8A7B0", tag: 3},
{name: "个人贷款", color: "#A8A7B0", tag: 3},
{name: "净资产", color: "#81CDB4", tag: 2},
{name: "投资理财", color: "#908DA2"},
{name: "后备隐藏能源", color: "#716C89"},
{name: "固定资产", color: "#839FAD"},
{name: "房产(自住)", color: "#5C8FA1"},
{name: "汽车", color: "#5C8FA1"},
{name: "应收款", color: "#908DA2"},
{name: "借给他人的钱", color: "#716C89"}
];
const links = [
{source: "信用卡", target: "总资产", value: 3000},
{source: "房屋贷款", target: "总资产", value: 500000},
{source: "车辆贷款", target: "总资产", value: 40000},
{source: "个人贷款", target: "总资产", value: 30000},
{source: "总资产", target: "固定资产", value: 850000},
{source: "固定资产", target: "房产(自住)", value: 800000},
{source: "固定资产", target: "汽车", value: 50000},
{source: "总资产", target: "投资理财", value: 50000},
{source: "投资理财", target: "后备隐藏能源", value: 50000},
{source: "总资产", target: "应收款", value: 20000},
{source: "应收款", target: "借给他人的钱", value: 20000}
];
// ------------------------------------------------------------------------------------------
// const log = (msg) => {
// const p = document.createElement('p')
// p.textContent = msg
// document.querySelector('#log').append(p)
// }
// 接收 iOS 发过来的参数
try {
webkit.messageHandlers.bridge.onMessage = (c, ns, ls) => {
// log('Params config: ' + JSON.stringify(c))
// log('Params nodes: ' + JSON.stringify(ns))
// log('Params links: ' + JSON.stringify(ls))
drawChart(c, ns, ls)
}
} catch (e) {
}
// 当页面加载完成时发送请求给 iOS
window.onload = () => {
try {
webkit.messageHandlers.bridge.postMessage('Ready')
} catch (e) {
drawChart(cfg, nodes, links)
}
}
</script>
</body>
</html>
//
// SwiftUIView.swift
//
//
// Created by no-today on 2024/6/14.
//
import SwiftUI
import WebKit
struct SankeyDiagram: UIViewRepresentable {
var config: SankeyConfig
var nodes: [SankeyNode]
var links: [NodeLink]
var isScrollEnabled: Bool
init(_ config: SankeyConfig, _ nodes: [SankeyNode], _ links: [NodeLink], _ isScrollEnabled: Bool = true
) {
self.config = config
self.nodes = nodes
self.links = links
self.isScrollEnabled = isScrollEnabled
}
// https://gist.github.com/JSerZANP/ea300d419bfafa79e4f8c0af42d8fec6
class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
var webView: WKWebView?
var parent: SankeyDiagram
init(_ parent: SankeyDiagram) {
self.parent = parent
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
self.webView = webView
}
// Receive message from webview
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
print("Received: \(message.body)")
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.drawSankeyDiagram(self.parent.config, self.parent.nodes, self.parent.links)
}
}
func drawSankeyDiagram(_ config: SankeyConfig, _ nodes: [SankeyNode], _ links: [NodeLink]) {
let cfg = toJSONString(config) ?? ""
let ns = toJSONString(nodes) ?? ""
let ls = toJSONString(links) ?? ""
print("SankeyDiagram width: \(config.width), height: \(config.height)")
// print("Nodes: \(ns)")
// print("Links: \(ls)")
self.webView?.evaluateJavaScript("webkit.messageHandlers.bridge.onMessage(\(cfg), \(ns), \(ls))")
}
func toJSONString<T: Codable>(_ object: T) -> String? {
let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
do {
let jsonData = try encoder.encode(object)
return String(data: jsonData, encoding: .utf8)
} catch {
print("Error encoding object to JSON: \(error)")
return nil
}
}
}
func makeCoordinator() -> Coordinator {
return Coordinator(self)
}
public func makeUIView(context: Context) -> WKWebView {
let coordinator = makeCoordinator()
let userContentController = WKUserContentController()
userContentController.add(coordinator, name: "bridge")
let configuration = WKWebViewConfiguration()
configuration.userContentController = userContentController
let _wkwebview = WKWebView(frame: .zero, configuration: configuration)
_wkwebview.navigationDelegate = coordinator
_wkwebview.isOpaque = false
_wkwebview.scrollView.showsVerticalScrollIndicator = false
_wkwebview.scrollView.showsHorizontalScrollIndicator = false
_wkwebview.scrollView.isScrollEnabled = isScrollEnabled
_wkwebview.scrollView.contentInset = .zero
_wkwebview.scrollView.contentInsetAdjustmentBehavior = .never
_wkwebview.scrollView.scrollIndicatorInsets = .zero
return _wkwebview
}
func updateUIView(_ webView: WKWebView, context: Context) {
guard let path: String = Bundle.main.path(forResource: "sankey-diagram", ofType: "html") else { return }
let localHTMLUrl = URL(fileURLWithPath: path, isDirectory: false)
webView.loadFileURL(localHTMLUrl, allowingReadAccessTo: localHTMLUrl)
}
}
#Preview {
SankeyDiagram(SankeyConfig(width: 700, extents: [90, 90, 5, 5]), nodes, links)
}
//
// SankeyNode.swift
//
//
// Created by no-today on 2024/6/17.
//
struct SankeyNode: Codable {
/// Node name
var name: String
/// Node color, used to control the color of rectangle node
var color: String
/// Special marks
/// 1: Total assets
/// 2: Net assets
/// 3: Debt
var tag: Int?
}
struct NodeLink: Codable {
/// Starting point of data flow, is the node name
var source: String
/// The end point of the data flow
var target: String
/// Numerical value
var value: Float
}
struct SankeyConfig: Codable {
/// The color used to control the filling between the source node and the target node
/// - static: No color, default gray
/// - source-target: Creates a gradient for the source-target color option.
/// - source: Use source color
/// - target: Use target color
var linkColor: String = "source"
/// Alignment
/// - sankeyLeft
/// - sankeyRight
/// - sankeyCenter
/// - sankeyJustify
var nodeAlign: String = "sankeyJustify"
/// Amount display mode
/// 0: Hide amount
/// 1: Show amount
/// 2: Percentage of display amount
var displayMode: DisplayMode = .show
/// Asset title location
/// 1: Always Left,
/// 2: Left and right
var titleMode: Int = 1
var totalAssetsText: String = "总资产"
var totalDebtText: String = "负债"
var totalAssetsRectColor: String?
var totalAssetsTextColor: String = "#8F8DB5"
var totalDebtTextColor: String = "#990000"
/// Width of the chart view
var width: Int
/// Height of the chart view
var height: Int = 300
/// Global font size
var fontSize: Int = 10
/// Width of the rectangle node
var nodeWidth: Int = 5
/// Node vertical spacing
var nodePadding: Int = 15
/// The distance between the title and the rectangle
var titlePadding: Int = 5
/// White space around
/// [x0, y0, x1, y1]
var extents: [Int]
/// Total assets rectangle extra bold width, based on the node width
var boldWidth: Int = 5
/// Total assets additional interval width, based on the node width
var dividerWidth: Int = 5
/// Summary box height
var assetsRectHeight: Int = 33
/// Summary box width
var assetsRectWidth: Int = 55
var assetsRectPadding: Int = 8
var assetsRectCornerRadius: Int = 5
var assetsRectTextX: Int = 5
var assetsRectTextDy: Float = 1.25
var assetsRectFontSize: Int = 11
}
enum DisplayMode: String, CaseIterable, Codable {
case show = "金额"
case percentage = "比例"
case hide = "隐藏金额"
case headline = "标题"
var id: Int {
switch self {
case .hide: return 0
case .show: return 1
case .percentage: return 2
case .headline: return 3
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(self.id)
}
}
let nodes = [
SankeyNode(name: "总资产", color: "#928DBD", tag: 1),
SankeyNode(name: "信用卡", color: "#A8A7B0", tag: 3),
SankeyNode(name: "房屋贷款", color: "#A8A7B0", tag: 3),
SankeyNode(name: "车辆贷款", color: "#A8A7B0", tag: 3),
SankeyNode(name: "个人贷款", color: "#A8A7B0", tag: 3),
SankeyNode(name: "净资产", color: "#81CDB4", tag: 2),
SankeyNode(name: "投资理财", color: "#908DA2"),
SankeyNode(name: "后备隐藏能源", color: "#716C89"),
SankeyNode(name: "固定资产", color: "#839FAD"),
SankeyNode(name: "房产(自住)", color: "#5C8FA1"),
SankeyNode(name: "汽车", color: "#5C8FA1"),
SankeyNode(name: "应收款", color: "#908DA2"),
SankeyNode(name: "借给他人的钱", color: "#716C89")
]
let links = [
NodeLink(source: "信用卡", target: "总资产", value: 3000),
NodeLink(source: "房屋贷款", target: "总资产", value: 500000),
NodeLink(source: "车辆贷款", target: "总资产", value: 40000),
NodeLink(source: "个人贷款", target: "总资产", value: 30000),
NodeLink(source: "总资产", target: "固定资产", value: 850000),
NodeLink(source: "固定资产", target: "房产(自住)", value: 800000),
NodeLink(source: "固定资产", target: "汽车", value: 50000),
NodeLink(source: "总资产", target: "投资理财", value: 50000),
NodeLink(source: "投资理财", target: "后备隐藏能源", value: 50000),
NodeLink(source: "总资产", target: "应收款", value: 20000),
NodeLink(source: "应收款", target: "借给他人的钱", value: 20000)
]
let nodes1 = [
SankeyNode(name: "总资产", color: "#A4A3AB", tag: 1),
SankeyNode(name: "4项负债", color: "#A8A7B0", tag: 3),
SankeyNode(name: "净资产", color: "#81CDB4", tag: 2),
SankeyNode(name: "投资理财", color: "#908DA2"),
SankeyNode(name: "后备隐藏能源", color: "#716C89"),
SankeyNode(name: "固定资产", color: "#839FAD"),
SankeyNode(name: "房产(自住)", color: "#F4F3F700"),
SankeyNode(name: "汽车", color: "#F4F3F700"),
SankeyNode(name: "应收款", color: "#F4F3F700"),
SankeyNode(name: "借给他人的钱", color: "#F4F3F700")
]
let links1 = [
NodeLink(source: "4项负债", target: "总资产", value: 573000),
NodeLink(source: "总资产", target: "固定资产", value: 850000),
NodeLink(source: "固定资产", target: "房产(自住)", value: 800000),
NodeLink(source: "固定资产", target: "汽车", value: 50000),
NodeLink(source: "总资产", target: "投资理财", value: 50000),
NodeLink(source: "总资产", target: "应收款", value: 20000),
NodeLink(source: "投资理财", target: "后备隐藏能源", value: 50000),
NodeLink(source: "应收款", target: "借给他人的钱", value: 20000)
]
@no-today
Copy link
Author

image image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment