搜索文章

输入关键词开始搜索

技术拆解-时间轴曲线组件

思考#技术拆解#数据可视化#架构#算法#数据结构

概述

这是一个基于 Stencil 框架的高级时间线组件,实现了蛇形布局和复杂曲线连接效果。组件使用 SVG 遮罩技术、多层渲染架构和渐变色系统,提供了专业级的可视化体验。

技术栈

  • 框架: Stencil (Web Components)
  • 语言: TypeScript
  • 样式: SCSS + UnoCSS
  • 图形: SVG 遮罩
  • 构建: Rollup

核心架构

1. 数据结构设计

// 节点数据接口
export interface NodeItem {
  time: string; // 时间显示
  title: string; // 节点标题
  description: string; // 节点描述
  point: boolean; // 是否为关键节点
  solid: boolean; // 连接线是否为实线
}

// 渲染项接口
export interface IRenderItem {
  type:
    | 'node'
    | 'line'
    | 'left-curve'
    | 'right-curve'
    | 'start-line'
    | 'end-line';
  title?: string;
  description?: string;
  solid?: boolean;
  point?: boolean;
  time?: string;
  index: number;
  bgColor?: string;
}

2. 蛇形布局算法

private processNodes() {
    const data = Array.isArray(this.data) ? this.data : JSON.parse(this.data);
    const rows: IRenderItem[][] = [];
    const nodesCopy = [...data];
    let index = 0;
    let currentIndex = 0;
    let rowIndex = 0;

    while (currentIndex < nodesCopy.length) {
        const row: IRenderItem[] = [];
        const isEvenRow = rowIndex % 2 === 0;

        if (currentIndex === 0) {
            row.push({ type: 'start-line', index });
            index++;
        }

        // 每行最多3个节点
        for (let i = 0; i < 3 && currentIndex < nodesCopy.length; i++) {
            const { solid } = nodesCopy[currentIndex];
            row.push({
                type: 'node',
                index,
                ...nodesCopy[currentIndex],
            });
            index++;

            if (i === 2 && currentIndex < nodesCopy.length - 1) {
                row.push({ type: `${isEvenRow ? 'left' : 'right'}-curve`, solid, index });
                index++;
            }

            if (i < 2 && currentIndex < nodesCopy.length - 1) {
                row.push({ type: 'line', solid, index });
                index++;
            }

            if (currentIndex === nodesCopy.length - 1) {
                row.push({ type: `${!isEvenRow ? 'start-line' : 'end-line'}`, index });
                index++;
            }
            currentIndex++;
        }

        // 偶数行正序,奇数行倒序
        if (!isEvenRow) {
            row.reverse();
        }

        rows.push(row);
        rowIndex++;
    }

    // 应用渐变色
    const colorList = ['#F564B9', '#733ED6', '#265FFC', '#26C6DA'];
    const gradientColors = this.getMultiGradientColors(colorList, index + 1);
    rows.forEach(row => {
        row.forEach(item => {
            item.bgColor = gradientColors[item.index];
        })
    });

    this.processedNodes = rows;
}

曲线实现详解

1. SVG 遮罩技术

原理说明

SVG 遮罩技术是曲线实现的核心,通过 SVG <mask> 元素创建复杂的剪切形状,然后将其应用到 CSS 渐变背景上。这种技术的优势在于:

  1. 复杂形状支持: 可以创建传统 CSS 难以实现的曲线形状
  2. 渐变背景兼容: 遮罩可以完美配合 CSS 渐变背景
  3. 矢量图形: 矢量路径确保在任何缩放级别下都保持清晰
  4. 性能优化: GPU 加速的遮罩渲染

遮罩工作机制

SVG 遮罩的工作原理:

  • 白色区域 (fill=“white”): 完全可见,显示背景内容
  • 黑色区域 (fill=“black”): 完全透明,隐藏背景内容
  • 灰色区域: 部分透明,实现渐变效果

左曲线遮罩详细实现

/**
 * 渲染左曲线组件
 * @param solid - 连接线是否为实线
 * @param bgColor - 渐变背景色
 * @returns JSX.Element
 */
private renderLeftCurve({ solid, bgColor }: IRenderItem ) {
    return (
        // 曲线容器:应用渐变背景和 SVG 遮罩
        <div class='left-curve' style={{
            background: bgColor,                    // 渐变背景色
            mask: 'url(#cutout-mask)'              // 应用 SVG 遮罩
        }}>
            {/* SVG 定义区域:尺寸设为 0x0,不占用布局空间 */}
            <svg width="0" height="0" style={{ position: 'absolute' }}>
                <defs>
                    {/* 定义遮罩:创建曲线形状 */}
                    <mask id="cutout-mask">
                        {/*
                         * 基础白色背景:整个遮罩区域默认完全可见
                         * 作为遮罩的基础画布
                         */}
                        <rect width="100%" height="100%" fill="white"/>

                        {/*
                         * 外层曲线路径:定义曲线的外边界轮廓
                         * 路径解析:从左上角开始,绘制 60px 半径的右弯弧线
                         * M 0,0: 移动到坐标 (0,0)
                         * L 72,0: 画直线到 (72,0)
                         * A 60,60 0 0 1 132,60: 画半径60px的弧线到 (132,60)
                         * L 132,105: 画直线到 (132,105)
                         * A 60,60 0 0 1 72,165: 画半径60px的弧线到 (72,165)
                         * L 0,165: 画直线到 (0,165)
                         * Z: 闭合路径
                         */}
                        <path d="M 0,0 L 72,0 A 60,60 0 0 1 132,60 L 132,105 A 60,60 0 0 1 72,165 L 0,165 Z" fill="white"/>

                        {/*
                         * 内层曲线路径:创建镂空区域,形成 20px 宽度的曲线带
                         * 使用 40px 半径,与外层路径形成 20px 的间距
                         * fill="black": 黑色填充表示该区域被镂空
                         */}
                        <path d="M 0,20 L 72,20 A 40,40 0 0 1 112,60 L 112,105 A 40,40 0 0 1 72,145 L 0,145 Z" fill="black"/>
                    </mask>
                </defs>
            </svg>

            {/* 实线边框:当 solid=true 时显示 */}
            <div class='left-curve-solid-top-line'></div>
            <div class='left-curve-solid-bottom-line'></div>

            {/* 曲线内容区域:包含内层曲线和虚线图标 */}
            <div class='left-curve-content' style={{
                border: solid ? '2px solid #000000' : '',  // 实线模式时显示边框
                borderLeft: 'none',                         // 移除左边框避免重叠
            }}>
                {/* 内层曲线:提供额外的视觉层次 */}
                <div class='left-inner-curve'></div>

                {/* 虚线图标:当 solid=false 时显示 */}
                {!solid && <img class="left-timeline-stop" src={this.theme === 'dark' ? timelineCurveStopWhite : timelineCurveStop} />}
            </div>
        </div>
    );
}

右曲线遮罩详细实现

/**
 * 渲染右曲线组件
 * @param solid - 连接线是否为实线
 * @param bgColor - 渐变背景色
 * @returns JSX.Element
 */
private renderRightCurve({ solid, bgColor }: IRenderItem ) {
    return (
        // 右曲线容器:镜像对称的左曲线
        <div class='right-curve' style={{
            background: bgColor,                         // 渐变背景色
            mask: 'url(#cutout-mask-rotated)'           // 应用旋转后的遮罩
        }}>
            {/* SVG 定义区域 */}
            <svg width="0" height="0" style={{ position: 'absolute' }}>
                <defs>
                    {/* 旋转遮罩:右曲线是左曲线的镜像 */}
                    <mask id="cutout-mask-rotated">
                        <rect width="100%" height="100%" fill="white"/>

                        {/*
                         * 外层曲线路径:左曲线的镜像版本
                         * 关键差异:sweep-flag 从 1 变为 0,实现逆时针弧线
                         * M 132,0: 从右上角开始
                         * L 60,0: 画直线到 (60,0)
                         * A 60,60 0 0 0 0,60: 逆时针画弧线到 (0,60)
                         * 其他坐标与左曲线对称
                         */}
                        <path d="M 132,0 L 60,0 A 60,60 0 0 0 0,60 L 0,105 A 60,60 0 0 0 60,165 L 132,165 Z" fill="white"/>

                        {/*
                         * 内层曲线路径:同样采用镜像设计
                         * 形成与左曲线相同宽度的曲线带
                         */}
                        <path d="M 132,20 L 60,20 A 40,40 0 0 0 20,60 L 20,105 A 40,40 0 0 0 60,145 L 132,145 Z" fill="black"/>
                    </mask>
                </defs>
            </svg>

            {/* 实线边框:与左曲线对称的布局 */}
            <div class='right-curve-solid-top-line'></div>
            <div class='right-curve-solid-bottom-line'></div>

            {/* 右曲线内容区域 */}
            <div class='right-curve-content' style={{
                border: solid ? '2px solid #000000' : '',  // 实线边框
                borderRight: 'none',                        // 移除右边框避免重叠
            }}>
                {/* 内层曲线:镜像设计 */}
                <div class='right-inner-curve'></div>

                {/* 虚线图标:旋转 180 度以适应右曲线方向 */}
                {!solid && <img class="right-timeline-stop" src={this.theme === 'dark' ? timelineCurveStopWhite : timelineCurveStop} />}
            </div>
        </div>
    );
}

2. SVG 路径解析

左曲线路径

<!-- 外层曲线路径 -->
<path d="M 0,0 L 72,0 A 60,60 0 0 1 132,60 L 132,105 A 60,60 0 0 1 72,165 L 0,165 Z" fill="white"/>

<!-- 内层曲线路径 -->
<path d="M 0,20 L 72,20 A 40,40 0 0 1 112,60 L 112,105 A 40,40 0 0 1 72,145 L 0,145 Z" fill="black"/>

右曲线路径

<!-- 外层曲线路径 -->
<path d="M 132,0 L 60,0 A 60,60 0 0 0 0,60 L 0,105 A 60,60 0 0 0 60,165 L 132,165 Z" fill="white"/>

<!-- 内层曲线路径 -->
<path d="M 132,20 L 60,20 A 40,40 0 0 0 20,60 L 20,105 A 40,40 0 0 0 60,145 L 132,145 Z" fill="black"/>

SVG 弧线参数解析:

  • A rx,ry x-axis-rotation large-arc-flag,sweep-flag x,y
  • rx,ry: 椭圆半径
  • large-arc-flag: 大弧标志 (0=小弧, 1=大弧)
  • sweep-flag: 弧线方向 (0=逆时针, 1=顺时针)

3. 多层渲染架构

// 左曲线样式
.left-curve {
  position: absolute;
  display: flex;
  align-items: center;
  right: 0px;
  top: 0px;
  height: 165px;
  width: 132px;
  border-radius: 0 60px 60px 0;

  // 实线边框 - z-index: 5
  .left-curve-solid-top-line {
    top: 10px;
    position: absolute;
    width: 35px;
    height: 1px;
    border-top: var(--viz-timeline-line-color) solid 2px;
    z-index: 5;
  }

  .left-curve-solid-bottom-line {
    bottom: 10px;
    position: absolute;
    width: 35px;
    height: 1px;
    border-bottom: var(--viz-timeline-line-color) solid 2px;
    z-index: 5;
  }

  // 内容区域 - z-index: 9-10
  .left-curve-content {
    position: relative;
    display: flex;
    align-items: center;
    width: 122px;
    border-radius: 0 50px 50px 0;
    height: 145px;

    .left-inner-curve {
      z-index: 9;
      height: 125px;
      width: 112px;
      border-radius: 0 40px 40px 0;
      border-left: none;
    }

    .left-timeline-stop {
      position: absolute;
      z-index: 10;
      height: 145px;
      width: 118px;
      left: 12px;
    }
  }
}

渐变色系统

1. 多色渐变算法

private getMultiGradientColors(colors: string[], steps: number): string[] {
    // 颜色转换函数
    const hexToRgb = (hex: string) => {
        const r = parseInt(hex.slice(1, 3), 16);
        const g = parseInt(hex.slice(3, 5), 16);
        const b = parseInt(hex.slice(5, 7), 16);
        return [r, g, b];
    };

    const rgbToHex = (r: number, g: number, b: number, theme: 'light' | 'dark') => {
        return `#${[r, g, b].map(x => x.toString(16).padStart(2, '0')).join('')}${theme === 'light' ? '1A' : '3A'}`;
    };

    // 计算每个颜色段的步数
    const segments = colors.length - 1;
    const segmentSteps = Math.floor(steps / segments);
    const remainder = steps % segments;

    let decomposedColors: string[] = [];
    for (let i = 0; i < segments; i++) {
        const currentSteps = i < remainder ? segmentSteps + 1 : segmentSteps;
        const startRgb = hexToRgb(colors[i]);
        const endRgb = hexToRgb(colors[i + 1]);

        for (let j = 0; j <= currentSteps; j++) {
            const ratio = j / currentSteps;
            const r = Math.round(startRgb[0] + (endRgb[0] - startRgb[0]) * ratio);
            const g = Math.round(startRgb[1] + (endRgb[1] - startRgb[1]) * ratio);
            const b = Math.round(startRgb[2] + (endRgb[2] - startRgb[2]) * ratio);
            decomposedColors.push(rgbToHex(r, g, b, this.theme));
        }
    }

    // 生成渐变色
    let gradientColors: string[] = [];
    for (let i = 0; i < steps; i++) {
        const startColor = decomposedColors[i];
        const endColor = decomposedColors[i + 1];
        gradientColors.push(`linear-gradient(90deg, ${startColor}, ${endColor})`);
    }

    return gradientColors;
}

2. 渐变色应用

// 在 processNodes 中应用渐变色
const colorList = ['#F564B9', '#733ED6', '#265FFC', '#26C6DA'];
const gradientColors = this.getMultiGradientColors(colorList, index + 1);
rows.forEach((row) => {
  row.forEach((item) => {
    item.bgColor = gradientColors[item.index];
  });
});

主题系统

1. CSS 变量定义

// 亮色主题
:root {
  --viz-timeline-dot-color: #000;
  --viz-timeline-line-color: #000;
  --viz-timeline-node-title-color: #000;
  --viz-timeline-node-time-bg-color: rgba(0, 0, 0, 0.05);
  --viz-timeline-curve-cover-color: #fff;
}

// 暗色主题
.dark-theme {
  --viz-timeline-dot-color: #fff;
  --viz-timeline-line-color: #fff;
  --viz-timeline-node-title-color: #fff;
  --viz-timeline-node-time-bg-color: rgba(255, 255, 255, 0.1);
  --viz-timeline-curve-cover-color: rgba(255, 255, 255, 0.1);
}

2. 主题切换

// 组件属性
@Prop() theme: 'light' | 'dark' = 'light';

// 在渲染中使用主题
<img
    class="left-timeline-stop"
    src={this.theme === 'dark' ? timelineCurveStopWhite : timelineCurveStop}
/>

关键技术特点

1. 蛇形布局

  • 偶数行从左到右排列
  • 奇数行从右到左排列
  • 每行最多 3 个节点
  • 自动计算连接元素

2. SVG 遮罩技术

  • 使用 SVG mask 创建复杂形状
  • 外层路径定义轮廓
  • 内层路径实现镂空效果
  • 支持 CSS 渐变背景

3. 多层渲染架构

  • z-index 层级管理
  • 绝对定位精确定位
  • 实线/虚线状态切换
  • 镜像对称实现

4. 渐变色系统

  • 多色渐变算法
  • 自动颜色插值
  • 透明度主题适配
  • 连续渐变效果

5. 响应式文本

  • 默认显示 3 行描述
  • 超过行数自动折叠
  • 点击展开完整内容
  • 省略号交互设计

使用示例

// 数据格式
const timelineData = [
  {
    time: '2024-01',
    title: '项目启动',
    description: '项目正式启动\n团队组建完成\n需求分析开始',
    point: true,
    solid: true,
  },
  {
    time: '2024-02',
    title: '设计阶段',
    description: 'UI/UX设计\n技术架构设计\n数据库设计',
    point: false,
    solid: false,
  },
  // ... 更多节点
];

// 在模板中使用
<story-line data={timelineData} theme="light" />;

性能优化

1. Shadow DOM 封装

  • 避免样式污染
  • 提高组件隔离性

2. 状态管理

  • 使用 @State 实现响应式更新
  • 避免不必要的重新渲染

3. 资源优化

  • SVG 图标预加载
  • 条件渲染减少 DOM 节点
  • CSS 变量减少样式计算

4. 内存优化

  • 组件卸载时清理状态
  • 避免循环引用
  • 合理使用缓存

浏览器兼容性

  • Chrome 60+
  • Firefox 55+
  • Safari 12+
  • Edge 79+

注意: 需要 CSS mask 属性支持,现代浏览器都支持此特性。

扩展性考虑

1. 自定义主题

  • 可以扩展支持更多主题
  • 支持自定义颜色配置

2. 动画效果

  • 可以添加渐变动画
  • 支持节点交互动画

3. 响应式设计

  • 可以适配不同屏幕尺寸
  • 支持移动端交互

4. 数据绑定

  • 支持动态数据更新
  • 可以集成状态管理

总结

这个时间线组件展示了现代前端开发的多个高级特性:

  • Web Components 封装
  • SVG 图形处理 技术
  • CSS 高级特性 应用
  • TypeScript 类型系统
  • 响应式设计 理念

通过综合运用这些技术,实现了一个功能完善、视觉效果优秀的时间线组件,为复杂的时间序列数据可视化提供了专业的解决方案。


自适应和缩放解决方案

当前方案的问题分析

原始实现存在以下自适应问题:

  1. 固定尺寸限制

    .left-curve {
      height: 165px; // 固定高度
      width: 132px; // 固定宽度
      border-radius: 0 60px 60px 0; // 固定圆角
    }
  2. SVG 路径硬编码

    <path d="M 0,0 L 72,0 A 60,60 0 0 1 132,60 L 132,105 A 60,60 0 0 1 72,165 L 0,165 Z"/>
  3. 像素级定位

    .left-curve-solid-top-line {
      top: 10px;
      width: 35px;
    }

解决方案一:CSS Transform 缩放

/**
 * 基础尺寸配置 - 作为缩放的基准
 */
const CURVE_BASE_SIZE = {
    width: 132,        // 基准宽度
    height: 165,       // 基准高度
    cornerRadius: 60,  // 基准圆角半径
    strokeWidth: 20    // 基准线条宽度
};

/**
 * 动态计算缩放比例
 * 基于容器宽度和节点间距计算合适的缩放比例
 */
private calculateScale(): number {
    const containerWidth = this.el.clientWidth;
    const nodeSpacing = containerWidth / 3; // 每行3个节点
    return Math.min(1, nodeSpacing / 200); // 基于容器宽度缩放
}

/**
 * 应用缩放样式到曲线元素
 * @param scale - 计算得出的缩放比例
 * @returns CSSStyleDeclaration
 */
private getCurveStyle(scale: number) {
    return {
        transform: `scale(${scale})`,              // 应用缩放
        transformOrigin: 'top left',              // 设置缩放原点
        width: `${CURVE_BASE_SIZE.width}px`,      // 保持基准宽度
        height: `${CURVE_BASE_SIZE.height}px`     // 保持基准高度
    };
}

解决方案二:SVG 路径动态生成

/**
 * 曲线配置接口
 */
interface CurveConfig {
    width: number;        // 曲线宽度
    height: number;       // 曲线高度
    outerRadius: number;  // 外层圆弧半径
    innerRadius: number;  // 内层圆弧半径
    strokeWidth: number;  // 线条宽度
}

/**
 * 动态生成 SVG 路径
 * @param config - 曲线配置参数
 * @param isLeft - 是否为左曲线
 * @returns 外层路径和内层路径
 */
private generateSVGPath(config: CurveConfig, isLeft: boolean = true): {outerPath: string, innerPath: string} {
    const { width, height, outerRadius, innerRadius, strokeWidth } = config;

    // 计算圆心位置
    const centerY = height / 2;

    // 计算起始和结束角度
    const startAngle = isLeft ? -Math.PI/2 : Math.PI/2;
    const endAngle = isLeft ? Math.PI/2 : -Math.PI/2;

    // 外弧路径计算
    const outerStartX = isLeft ? width - outerRadius : outerRadius;
    const outerStartY = centerY - outerRadius;
    const outerEndX = isLeft ? width - outerRadius : outerRadius;
    const outerEndY = centerY + outerRadius;

    // 生成外弧路径字符串
    const outerPath = `M ${isLeft ? width : 0},${centerY - outerRadius}
                       A ${outerRadius},${outerRadius} 0 0,${isLeft ? 1 : 0} ${outerEndX},${outerEndY}`;

    // 内弧路径计算
    const innerStartX = isLeft ? width - innerRadius : innerRadius;
    const innerStartY = centerY - innerRadius;
    const innerEndX = isLeft ? width - innerRadius : innerRadius;
    const innerEndY = centerY + innerRadius;

    // 生成内弧路径字符串
    const innerPath = `M ${isLeft ? width : 0},${centerY - innerRadius}
                       A ${innerRadius},${innerRadius} 0 0,${isLeft ? 1 : 0} ${innerEndX},${innerEndY}`;

    return { outerPath, innerPath };
}

/**
 * 渲染动态曲线组件
 * @param config - 曲线配置
 * @param bgColor - 背景色
 * @param solid - 是否为实线
 * @param isLeft - 是否为左曲线
 * @returns JSX.Element
 */
private renderDynamicCurve(config: CurveConfig, bgColor: string, solid: boolean, isLeft: boolean): JSX.Element {
    const { outerPath, innerPath } = this.generateSVGPath(config, isLeft);
    const maskId = isLeft ? 'dynamic-cutout-mask' : 'dynamic-cutout-mask-right';

    return (
        <div class={`${isLeft ? 'left' : 'right'}-curve dynamic-curve`} style={{
            background: bgColor,
            mask: `url(#${maskId})`,
            width: `${config.width}px`,
            height: `${config.height}px`
        }}>
            {/* 动态 SVG 遮罩定义 */}
            <svg width="0" height="0" style={{ position: 'absolute' }}>
                <defs>
                    <mask id={maskId}>
                        <rect width="100%" height="100%" fill="white"/>
                        <path d={outerPath} fill="white"/>
                        <path d={innerPath} fill="black"/>
                    </mask>
                </defs>
            </svg>

            {/* 实线边框元素 */}
            <div class={`${isLeft ? 'left' : 'right'}-curve-solid-top-line`}></div>
            <div class={`${isLeft ? 'left' : 'right'}-curve-solid-bottom-line`}></div>

            {/* 曲线内容区域 */}
            <div class={`${isLeft ? 'left' : 'right'}-curve-content`} style={{
                border: solid ? '2px solid #000000' : '',
                [isLeft ? 'borderLeft' : 'borderRight']: 'none',
            }}>
                <div class={`${isLeft ? 'left' : 'right'}-inner-curve`}></div>
                {!solid && (
                    <img class={`${isLeft ? 'left' : 'right'}-timeline-stop`}
                         src={this.theme === 'dark' ? timelineCurveStopWhite : timelineCurveStop} />
                )}
            </div>
        </div>
    );
}

解决方案三:CSS 变量动态计算

/**
 * 使用 CSS 变量实现动态尺寸和响应式曲线
 * 通过 CSS 变量计算实际尺寸,避免硬编码
 */

:root {
  /* 基础尺寸变量 */
  --curve-width: 132px;
  --curve-height: 165px;
  --curve-outer-radius: 60px;
  --curve-inner-radius: 40px;
  --curve-stroke-width: 20px;
  --curve-spacing: 10px;
}

/* 响应式曲线容器 */
.timeline-curve-container {
  /* 缩放比例变量,由 JavaScript 动态设置 */
  --curve-scale: 1;

  /* 实际尺寸计算 */
  --actual-width: calc(var(--curve-width) * var(--curve-scale));
  --actual-height: calc(var(--curve-height) * var(--curve-scale));
  --actual-outer-radius: calc(var(--curve-outer-radius) * var(--curve-scale));
  --actual-inner-radius: calc(var(--curve-inner-radius) * var(--curve-scale));
  --actual-stroke-width: calc(var(--curve-stroke-width) * var(--curve-scale));

  /* 应用计算后的尺寸 */
  width: var(--actual-width);
  height: var(--actual-height);
}

/* 左曲线动态样式 */
.left-curve {
  border-radius: 0 var(--actual-outer-radius) var(--actual-outer-radius) 0;

  .left-curve-content {
    /* 减去边距后的内容区域尺寸 */
    width: calc(var(--actual-width) - 10px * var(--curve-scale));
    height: calc(var(--actual-height) - 20px * var(--curve-scale));
    border-radius: 0 calc(
        var(--actual-outer-radius) - 10px * var(--curve-scale)
      ) calc(var(--actual-outer-radius) - 10px * var(--curve-scale)) 0;

    .left-inner-curve {
      /* 内层曲线尺寸 */
      width: calc(var(--actual-width) - 20px * var(--curve-scale));
      height: calc(var(--actual-height) - 40px * var(--curve-scale));
      border-radius: 0 calc(
          var(--actual-inner-radius) - 10px * var(--curve-scale)
        ) calc(var(--actual-inner-radius) - 10px * var(--curve-scale)) 0;
    }
  }
}

/* 右曲线动态样式 */
.right-curve {
  border-radius: var(--actual-outer-radius) 0 0 var(--actual-outer-radius);

  .right-curve-content {
    width: calc(var(--actual-width) - 10px * var(--curve-scale));
    height: calc(var(--actual-height) - 20px * var(--curve-scale));
    border-radius: calc(var(--actual-outer-radius) - 10px * var(--curve-scale)) 0
      0 calc(var(--actual-outer-radius) - 10px * var(--curve-scale));

    .right-inner-curve {
      width: calc(var(--actual-width) - 20px * var(--curve-scale));
      height: calc(var(--actual-height) - 40px * var(--curve-scale));
      border-radius: calc(
          var(--actual-inner-radius) - 10px * var(--curve-scale)
        ) 0 0 calc(var(--actual-inner-radius) - 10px * var(--curve-scale));
    }
  }
}

解决方案四:响应式布局系统

/**
 * 响应式断点配置
 */
const BREAKPOINTS = {
  mobile: 480, // 移动端断点
  tablet: 768, // 平板端断点
  desktop: 1024, // 桌面端断点
  large: 1440, // 大屏幕断点
};

/**
 * 响应式配置对象
 */
const RESPONSIVE_CONFIG = {
  mobile: {
    nodesPerRow: 1, // 每行1个节点
    curveWidth: 80, // 曲线宽度
    curveHeight: 100, // 曲线高度
    fontSize: 12, // 字体大小
    spacing: 15, // 间距
  },
  tablet: {
    nodesPerRow: 2, // 每行2个节点
    curveWidth: 100, // 曲线宽度
    curveHeight: 120, // 曲线高度
    fontSize: 14, // 字体大小
    spacing: 20, // 间距
  },
  desktop: {
    nodesPerRow: 3, // 每行3个节点
    curveWidth: 132, // 曲线宽度
    curveHeight: 165, // 曲线高度
    fontSize: 16, // 字体大小
    spacing: 25, // 间距
  },
};

/**
 * 响应式时间线类
 * 管理断点检测和布局更新
 */
class ResponsiveTimeline {
  private currentBreakpoint: string;
  private resizeObserver: ResizeObserver;

  constructor(private element: HTMLElement) {
    this.setupResizeObserver();
    this.updateLayout();
  }

  /**
   * 设置 ResizeObserver 监听容器尺寸变化
   */
  private setupResizeObserver() {
    this.resizeObserver = new ResizeObserver((entries) => {
      this.updateLayout();
    });

    this.resizeObserver.observe(this.element);
  }

  /**
   * 获取当前断点
   * @returns 当前断点名称
   */
  private getCurrentBreakpoint(): string {
    const width = this.element.clientWidth;

    if (width <= BREAKPOINTS.mobile) return 'mobile';
    if (width <= BREAKPOINTS.tablet) return 'tablet';
    if (width <= BREAKPOINTS.desktop) return 'desktop';
    return 'large';
  }

  /**
   * 更新布局配置
   */
  private updateLayout() {
    const newBreakpoint = this.getCurrentBreakpoint();

    if (newBreakpoint !== this.currentBreakpoint) {
      this.currentBreakpoint = newBreakpoint;
      this.applyLayout();
    }
  }

  /**
   * 应用布局配置
   */
  private applyLayout() {
    const config = RESPONSIVE_CONFIG[this.currentBreakpoint];

    // 更新CSS变量
    this.element.style.setProperty('--curve-width', `${config.curveWidth}px`);
    this.element.style.setProperty('--curve-height', `${config.curveHeight}px`);
    this.element.style.setProperty('--font-size', `${config.fontSize}px`);
    this.element.style.setProperty('--spacing', `${config.spacing}px`);
    this.element.style.setProperty(
      '--nodes-per-row',
      config.nodesPerRow.toString()
    );

    // 触发重新渲染
    this.processNodes(config.nodesPerRow);
  }

  /**
   * 根据新的节点数重新处理布局
   * @param nodesPerRow - 每行节点数
   */
  private processNodes(nodesPerRow: number) {
    // 重新计算节点布局逻辑
    // ...
  }

  /**
   * 清理资源
   */
  destroy() {
    this.resizeObserver.disconnect();
  }
}

综合解决方案:CSS Grid + SVG 动态生成

/**
 * 推荐的综合解决方案
 * 结合 CSS Grid 布局和 SVG 动态生成的优势
 */
@Component({
  tag: 'responsive-story-line',
  styleUrl: 'responsive-story-line.scss',
  shadow: true,
})
export class ResponsiveStoryLine {
  @Prop() data!: NodeItem[] | string;
  @Prop() theme: 'light' | 'dark' = 'light';

  @Element() el: HTMLElement;
  @State() processedNodes: IRenderItem[][] = [];
  @State() layoutConfig: LayoutConfig;

  private resizeObserver: ResizeObserver;

  componentDidLoad() {
    this.setupResizeObserver();
    this.updateLayout();
  }

  disconnectedCallback() {
    this.resizeObserver?.disconnect();
  }

  /**
   * 设置 ResizeObserver 监听容器尺寸变化
   */
  private setupResizeObserver() {
    this.resizeObserver = new ResizeObserver(() => {
      this.updateLayout();
    });

    this.resizeObserver.observe(this.el);
  }

  /**
   * 更新布局配置
   */
  private updateLayout() {
    const width = this.el.clientWidth;
    this.layoutConfig = this.calculateLayoutConfig(width);
    this.processNodes();
  }

  /**
   * 计算布局配置
   * @param width - 容器宽度
   * @returns 布局配置对象
   */
  private calculateLayoutConfig(width: number): LayoutConfig {
    const nodesPerRow = width >= 1024 ? 3 : width >= 768 ? 2 : 1;
    const scale = Math.min(1, width / (nodesPerRow * 200));

    return {
      nodesPerRow,
      scale,
      curveWidth: 132 * scale,
      curveHeight: 165 * scale,
      nodeSpacing: (width - 100) / nodesPerRow,
    };
  }

  /**
   * 动态生成 SVG 路径
   * @param config - 布局配置
   * @param isLeft - 是否为左曲线
   * @returns SVG 路径字符串
   */
  private generateSVGPath(config: LayoutConfig, isLeft: boolean): string {
    const { curveWidth, curveHeight } = config;
    const radius = curveHeight / 2;

    if (isLeft) {
      return `M ${curveWidth - radius},0 A ${radius},${radius} 0 0,1 ${
        curveWidth - radius
      },${curveHeight}`;
    } else {
      return `M ${radius},0 A ${radius},${radius} 0 0,0 ${radius},${curveHeight}`;
    }
  }

  /**
   * 渲染曲线组件
   * @param config - 布局配置
   * @param isLeft - 是否为左曲线
   * @returns JSX.Element
   */
  private renderCurve(config: LayoutConfig, isLeft: boolean): JSX.Element {
    const maskId = isLeft ? 'curve-mask-left' : 'curve-mask-right';
    const path = this.generateSVGPath(config, isLeft);

    return (
      <div
        class={`curve ${isLeft ? 'left' : 'right'}`}
        style={{
          width: `${config.curveWidth}px`,
          height: `${config.curveHeight}px`,
          transform: `scale(${config.scale})`,
          transformOrigin: isLeft ? 'top right' : 'top left',
        }}
      >
        <svg width="0" height="0">
          <defs>
            <mask id={maskId}>
              <rect width="100%" height="100%" fill="white" />
              <path d={path} fill="white" stroke="black" strokeWidth="20" />
            </mask>
          </defs>
        </svg>
      </div>
    );
  }

  render() {
    return (
      <div
        class="responsive-timeline"
        style={{
          '--curve-scale': this.layoutConfig.scale,
          '--nodes-per-row': this.layoutConfig.nodesPerRow,
        }}
      >
        {this.processedNodes.map((row, rowIndex) => (
          <div class="timeline-row" key={rowIndex}>
            {row.map((item, itemIndex) => {
              if (item.type === 'left-curve') {
                return this.renderCurve(this.layoutConfig, true);
              } else if (item.type === 'right-curve') {
                return this.renderCurve(this.layoutConfig, false);
              }
              // 其他元素渲染...
            })}
          </div>
        ))}
      </div>
    );
  }
}

CSS Grid 布局方案

/**
 * CSS Grid 响应式布局方案
 * 使用 Grid 布局实现真正的自适应
 */
.responsive-timeline {
  display: grid;
  gap: 20px;
  width: 100%;

  .timeline-row {
    display: grid;
    grid-template-columns: repeat(var(--nodes-per-row), 1fr);
    gap: 20px;
    align-items: center;
    position: relative;

    /* 时间线基线 */
    &::before {
      content: '';
      position: absolute;
      top: 50%;
      left: 0;
      right: 0;
      height: 2px;
      background: var(--viz-timeline-line-color);
      z-index: 1;
    }
  }

  .timeline-node {
    position: relative;
    z-index: 2;
    background: var(--viz-card-bg-color);
    border-radius: 8px;
    padding: 16px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);

    .node-point {
      width: 12px;
      height: 12px;
      background: var(--viz-timeline-dot-color);
      border-radius: 50%;
      position: absolute;
      top: -6px;
      left: 50%;
      transform: translateX(-50%);
    }
  }

  .curve {
    position: absolute;
    top: 100%;
    z-index: 3;

    &.left {
      right: 0;
      transform-origin: top right;
    }

    &.right {
      left: 0;
      transform-origin: top left;
    }
  }
}

/* 响应式媒体查询 */
@media (max-width: 768px) {
  .responsive-timeline {
    .timeline-row {
      grid-template-columns: 1fr;
    }
  }
}

@media (max-width: 480px) {
  .responsive-timeline {
    gap: 15px;

    .timeline-row {
      gap: 15px;
    }

    .timeline-node {
      padding: 12px;
      font-size: 14px;
    }
  }
}

性能优化建议

/**
 * 1. 防抖处理
 * 避免频繁的 resize 事件触发重绘
 */
private resizeObserver = new ResizeObserver(
    debounce(() => this.updateLayout(), 100)
);

/**
 * 2. 路径缓存机制
 * 避免重复计算相同的 SVG 路径
 */
private pathCache = new Map<string, string>();

private generateSVGPath(config: LayoutConfig, isLeft: boolean): string {
    const cacheKey = `${config.curveWidth}-${config.curveHeight}-${isLeft}`;

    if (this.pathCache.has(cacheKey)) {
        return this.pathCache.get(cacheKey)!;
    }

    const path = this.calculatePath(config, isLeft);
    this.pathCache.set(cacheKey, path);
    return path;
}

/**
 * 3. 虚拟化渲染
 * 只渲染可见区域的节点,提升大数据量性能
 */
private getVisibleNodes(): IRenderItem[][] {
    const scrollTop = this.el.scrollTop;
    const clientHeight = this.el.clientHeight;

    return this.processedNodes.filter((row, index) => {
        const rowTop = index * 200; // 估算每行高度
        return rowTop + 200 >= scrollTop && rowTop <= scrollTop + clientHeight;
    });
}

推荐方案总结

最佳实践: CSS Grid + SVG 动态生成 的综合方案

优势:

  • ✅ 完美的自适应能力
  • ✅ 保持原有的视觉效果
  • ✅ 优秀的性能表现
  • ✅ 良好的代码可维护性
  • ✅ 支持各种屏幕尺寸

适用场景:

  • 响应式 Web 应用
  • 移动端适配
  • 大屏可视化
  • 动态数据展示

通过这些解决方案,可以显著提升曲线组件的自适应能力,为用户提供更好的跨设备体验。