diff --git a/src/ui/axis/axis.ts b/src/ui/axis/axis.ts index edc1b737..2f089f4a 100644 --- a/src/ui/axis/axis.ts +++ b/src/ui/axis/axis.ts @@ -11,6 +11,8 @@ import { renderAxisLine } from './guides/line'; import { renderTicks } from './guides/ticks'; import { renderTitle } from './guides/title'; import type { AxisDatum, AxisOptions, AxisStyleProps, RequiredAxisStyleProps } from './types'; +import { applyClassName, getAxisClassName } from './utils/classname'; +import { CLASSNAME_SUFFIX_MAP } from './classname-map'; export type { ArcAxisOptions, @@ -27,9 +29,10 @@ function renderAxisMain( data: AxisDatum[], animation: StandardAnimationOption ) { - const { showLine, showTick, showLabel } = attributes; + const { showLine, showTick, showLabel, classNamePrefix } = attributes; /** line */ const lineGroup = container.maybeAppendByClassName(CLASS_NAMES.lineGroup, 'g'); + applyClassName(lineGroup, CLASS_NAMES.lineGroup, CLASSNAME_SUFFIX_MAP.lineGroup, classNamePrefix); const lineTransitions = ifShow(showLine!, lineGroup, (group) => { return renderAxisLine(group, attributes, animation); @@ -37,6 +40,7 @@ function renderAxisMain( /** tick */ const tickGroup = container.maybeAppendByClassName(CLASS_NAMES.tickGroup, 'g'); + applyClassName(tickGroup, CLASS_NAMES.tickGroup, CLASSNAME_SUFFIX_MAP.tickGroup, classNamePrefix); const tickTransitions = ifShow(showTick!, tickGroup, (group) => { return renderTicks(group, data, attributes, animation); @@ -44,6 +48,7 @@ function renderAxisMain( /** label */ const labelGroup = container.maybeAppendByClassName(CLASS_NAMES.labelGroup, 'g'); + applyClassName(labelGroup, CLASS_NAMES.labelGroup, CLASSNAME_SUFFIX_MAP.labelGroup, classNamePrefix); const labelTransitions = ifShow(showLabel!, labelGroup, (group) => { return renderLabels(group, data, attributes, animation, container.node()); @@ -58,7 +63,16 @@ export class Axis extends Component { } render(attributes: RequiredAxisStyleProps, container: Group, specificAnimation?: GenericAnimation) { - const { titleText, data, animate, showTitle, showGrid, dataThreshold, truncRange } = attributes; + const { titleText, data, animate, showTitle, showGrid, dataThreshold, truncRange, classNamePrefix } = attributes; + + // Set root container className + const baseClassName = container.className || 'axis'; + if (classNamePrefix) { + container.attr('className', `${baseClassName} ${classNamePrefix}axis`); + } else if (!container.className) { + container.attr('className', 'axis'); + } + const sampledData = sampling(data, dataThreshold).filter(({ value }) => { if (truncRange && value > truncRange[0] && value < truncRange[1]) return false; return true; @@ -68,11 +82,13 @@ export class Axis extends Component { /** grid */ const gridGroup = select(container).maybeAppendByClassName(CLASS_NAMES.gridGroup, 'g'); + applyClassName(gridGroup, CLASS_NAMES.gridGroup, CLASSNAME_SUFFIX_MAP.gridGroup, classNamePrefix); const gridTransitions = ifShow(showGrid!, gridGroup, (group) => renderGrid(group, sampledData, attributes, finalAnimation)) || []; /** main group */ const mainGroup = select(container).maybeAppendByClassName(CLASS_NAMES.mainGroup, 'g'); + applyClassName(mainGroup, CLASS_NAMES.mainGroup, CLASSNAME_SUFFIX_MAP.mainGroup, classNamePrefix); if (titleText && ((!this.initialized && finalAnimation.enter) || (this.initialized && finalAnimation.update))) { renderAxisMain(attributes, select(this.offscreenGroup), sampledData, parseAnimationOption(false)); @@ -81,6 +97,7 @@ export class Axis extends Component { const mainTransitions = renderAxisMain(attributes, select(mainGroup.node()), sampledData, finalAnimation); /** title */ const titleGroup = select(container).maybeAppendByClassName(CLASS_NAMES.titleGroup, 'g'); + applyClassName(titleGroup, CLASS_NAMES.titleGroup, CLASSNAME_SUFFIX_MAP.titleGroup, classNamePrefix); const titleTransitions = ifShow(showTitle, titleGroup, (group) => { return renderTitle(group, this, attributes, finalAnimation); diff --git a/src/ui/axis/classname-map.ts b/src/ui/axis/classname-map.ts new file mode 100644 index 00000000..950c9bc3 --- /dev/null +++ b/src/ui/axis/classname-map.ts @@ -0,0 +1,22 @@ +export const CLASSNAME_SUFFIX_MAP = { + // group + gridGroup: 'grid-group', + mainGroup: 'main-group', + lineGroup: 'line-group', + tickGroup: 'tick-group', + labelGroup: 'label-group', + titleGroup: 'title-group', + + // content + grid: 'grid', + line: 'line', + lineFirst: 'line-first', + lineSecond: 'line-second', + tick: 'tick', + tickItem: 'tick-item', + label: 'label', + labelItem: 'label-item', + title: 'title', +} as const; + +export type ClassNameSuffix = keyof typeof CLASSNAME_SUFFIX_MAP; diff --git a/src/ui/axis/constant.ts b/src/ui/axis/constant.ts index c7827e39..daadd9b8 100644 --- a/src/ui/axis/constant.ts +++ b/src/ui/axis/constant.ts @@ -3,6 +3,8 @@ import { Path } from '../../shapes'; import { classNames } from '../../util'; import type { AxisBaseStyleProps } from './types'; +import { CLASSNAME_SUFFIX_MAP } from './classname-map'; + export const AXIS_BASE_DEFAULT_ATTR: Partial = { data: [], animate: { @@ -76,21 +78,21 @@ export const HELIX_DEFAULT_OPTIONS = deepMix({}, AXIS_BASE_DEFAULT_ATTR, { export const CLASS_NAMES = classNames( { - mainGroup: 'main-group', - gridGroup: 'grid-group', - grid: 'grid', - lineGroup: 'line-group', - line: 'line', - tickGroup: 'tick-group', - tick: 'tick', - tickItem: 'tick-item', - labelGroup: 'label-group', - label: 'label', - labelItem: 'label-item', - titleGroup: 'title-group', - title: 'title', - lineFirst: 'line-first', - lineSecond: 'line-second', + mainGroup: CLASSNAME_SUFFIX_MAP.mainGroup, + gridGroup: CLASSNAME_SUFFIX_MAP.gridGroup, + grid: CLASSNAME_SUFFIX_MAP.grid, + lineGroup: CLASSNAME_SUFFIX_MAP.lineGroup, + line: CLASSNAME_SUFFIX_MAP.line, + tickGroup: CLASSNAME_SUFFIX_MAP.tickGroup, + tick: CLASSNAME_SUFFIX_MAP.tick, + tickItem: CLASSNAME_SUFFIX_MAP.tickItem, + labelGroup: CLASSNAME_SUFFIX_MAP.labelGroup, + label: CLASSNAME_SUFFIX_MAP.label, + labelItem: CLASSNAME_SUFFIX_MAP.labelItem, + titleGroup: CLASSNAME_SUFFIX_MAP.titleGroup, + title: CLASSNAME_SUFFIX_MAP.title, + lineFirst: CLASSNAME_SUFFIX_MAP.lineFirst, + lineSecond: CLASSNAME_SUFFIX_MAP.lineSecond, }, 'axis' ); diff --git a/src/ui/axis/guides/grid.ts b/src/ui/axis/guides/grid.ts index c901ac80..2a8e3c8e 100644 --- a/src/ui/axis/guides/grid.ts +++ b/src/ui/axis/guides/grid.ts @@ -7,6 +7,8 @@ import { CLASS_NAMES } from '../constant'; import type { AxisDatum, AxisGridStyleProps, AxisStyleProps } from '../types'; import { getValuePos } from './line'; import { filterExec, getDirectionVector } from './utils'; +import { applyClassName } from '../utils/classname'; +import { CLASSNAME_SUFFIX_MAP } from '../classname-map'; function getGridVector(value: number, attr: Required) { return getDirectionVector(value, attr.gridDirection, attr); @@ -63,6 +65,7 @@ export function renderGrid( attr: Required, animate: StandardAnimationOption ) { + const { classNamePrefix } = attr; const gridAttr = subStyleProps>(attr, 'grid'); const { type, areaFill } = gridAttr; const center = getGridCenter(attr); @@ -83,7 +86,11 @@ export function renderGrid( .selectAll(CLASS_NAMES.grid.class) .data([1]) .join( - (enter) => enter.append(() => new Grid({ style })).attr('className', CLASS_NAMES.grid.name), + (enter) => { + const grid = enter.append(() => new Grid({ style })).attr('className', CLASS_NAMES.grid.name); + applyClassName(grid, CLASS_NAMES.grid, CLASSNAME_SUFFIX_MAP.grid, classNamePrefix); + return grid; + }, (update) => update.transition(function () { return this.update(style); diff --git a/src/ui/axis/guides/labels.ts b/src/ui/axis/guides/labels.ts index 2f92001f..8012eb42 100644 --- a/src/ui/axis/guides/labels.ts +++ b/src/ui/axis/guides/labels.ts @@ -30,6 +30,8 @@ import type { AxisDatum, AxisLabelStyleProps, AxisStyleProps } from '../types'; import { getFactor } from '../utils'; import { getValuePos } from './line'; import { filterExec, getCallbackStyle, getLabelVector, getLineTangentVector } from './utils'; +import { applyClassName } from '../utils/classname'; +import { CLASSNAME_SUFFIX_MAP } from '../classname-map'; function angleNormalizer(angle: number) { let normalizedAngle = angle; @@ -198,7 +200,9 @@ function overlapHandler(attr: Required, main: DisplayObject) { wrap: (label, width, lines) => { label && wrapIt(label, width, lines); }, - getTextShape: (label) => label.querySelector('text') as Text, + getTextShape: (label) => { + return label.querySelector(CLASS_NAMES.labelItem.class) as Text; + }, }); } @@ -210,11 +214,13 @@ function renderLabel( attr: Required ): DisplayObject { const index = data.indexOf(datum); - const { labelRender } = attr; + const { labelRender, classNamePrefix } = attr; + const label = select(container) .append(labelRender ? renderHTMLLabel(datum, index, data, attr) : formatter(datum, index, data, attr)) .attr('className', CLASS_NAMES.labelItem.name) .node(); + applyClassName(select(label), CLASS_NAMES.labelItem, CLASSNAME_SUFFIX_MAP.labelItem, classNamePrefix); const [labelStyle, { transform, ...groupStyle }] = splitStyle(getCallbackStyle(style, [datum, index, data])); percentTransform(label, transform); @@ -238,24 +244,28 @@ export function renderLabels( animate: StandardAnimationOption, main: DisplayObject ) { + const { classNamePrefix } = attr; const finalData = filterExec(data, attr.labelFilter); const style = subStyleProps(attr, 'label'); + let _exit!: Selection; const transitions = container .selectAll(CLASS_NAMES.label.class) .data(finalData, (d, i) => i) .join( - (enter) => - enter + (enter) => { + const labels = enter .append('g') .attr('className', CLASS_NAMES.label.name) .transition(function (datum) { renderLabel(this, datum, data, style, attr); const { x, y } = getLabelPos(datum, data, attr); - // .axis-label this.style.transform = `translate(${x}, ${y})`; return null; - }), + }); + applyClassName(labels, CLASS_NAMES.label, CLASSNAME_SUFFIX_MAP.label, classNamePrefix); + return labels; + }, (update) => update.transition(function (datum) { const prevLabel = this.querySelector(CLASS_NAMES.labelItem.class); diff --git a/src/ui/axis/guides/line.ts b/src/ui/axis/guides/line.ts index 2bc58795..66fe0a51 100644 --- a/src/ui/axis/guides/line.ts +++ b/src/ui/axis/guides/line.ts @@ -15,6 +15,8 @@ import { import { CLASS_NAMES } from '../constant'; import type { RequiredArcAxisStyleProps, RequiredAxisStyleProps, RequiredLinearAxisStyleProps } from '../types'; import { getLineAngle, getLineTangentVector } from './utils'; +import { applyClassName, getAxisClassName } from '../utils/classname'; +import { CLASSNAME_SUFFIX_MAP } from '../classname-map'; type LineDatum = { line: [Vector2, Vector2]; @@ -97,18 +99,21 @@ function renderArc( style: RequiredArcAxisStyleProps, animate: StandardAnimationOption ) { - const { startAngle, endAngle, center, radius } = attr; + const { startAngle, endAngle, center, radius, classNamePrefix } = attr; return container .selectAll(CLASS_NAMES.line.class) .data([{ d: getArcPath(startAngle, endAngle, ...center, radius) }], (d, i) => i) .join( - (enter) => - enter + (enter) => { + const line = enter .append('path') .attr('className', CLASS_NAMES.line.name) .styles(attr) - .styles({ d: (d: any) => d.d }), + .styles({ d: (d: any) => d.d }); + applyClassName(line, CLASS_NAMES.line, CLASSNAME_SUFFIX_MAP.line, classNamePrefix); + return line; + }, (update) => update .transition(function () { @@ -158,7 +163,7 @@ function renderLinear( style: RequiredLinearAxisStyleProps, animate: StandardAnimationOption ) { - const { showTrunc, startPos, endPos, truncRange, lineExtension } = attr; + const { showTrunc, startPos, endPos, truncRange, lineExtension, classNamePrefix } = attr; const [[x1, y1], [x2, y2]] = [startPos, endPos]; const [ox1, oy1, ox2, oy2] = lineExtension ? extendLine(startPos, endPos, lineExtension) : new Array(4).fill(0); const renderLine = (data: LineDatum[]) => { @@ -166,14 +171,43 @@ function renderLinear( .selectAll(CLASS_NAMES.line.class) .data(data, (d, i) => i) .join( - (enter) => - enter + (enter) => { + const lines = enter .append('line') - .attr('className', (d: LineDatum) => `${CLASS_NAMES.line.name} ${d.className}`) .styles(style) .transition(function (d: LineDatum) { return transition(this, getLinePath(d.line), false); - }), + }); + // Set className with appropriate logic for line elements + lines.attr('className', (d: LineDatum) => { + if (!classNamePrefix) { + return `${CLASS_NAMES.line.name} ${d.className}`; + } + const baseLineClassName = getAxisClassName( + CLASS_NAMES.line.name, + CLASSNAME_SUFFIX_MAP.line, + classNamePrefix + ); + if (d.className === CLASS_NAMES.lineFirst.name) { + const specificClassName = getAxisClassName( + CLASS_NAMES.lineFirst.name, + CLASSNAME_SUFFIX_MAP.lineFirst, + classNamePrefix + ); + return `${baseLineClassName} ${specificClassName}`; + } + if (d.className === CLASS_NAMES.lineSecond.name) { + const specificClassName = getAxisClassName( + CLASS_NAMES.lineSecond.name, + CLASSNAME_SUFFIX_MAP.lineSecond, + classNamePrefix + ); + return `${baseLineClassName} ${specificClassName}`; + } + return baseLineClassName; + }); + return lines; + }, (update) => update.styles(style).transition(function ({ line }: LineDatum) { return transition(this, getLinePath(line), animate.update); @@ -228,9 +262,13 @@ function renderAxisArrow( const { showArrow, showTrunc, lineArrow, lineArrowOffset, lineArrowSize } = attr; let shapeToAddArrow: Selection; - if (type === 'arc') shapeToAddArrow = container.select(CLASS_NAMES.line.class); - else if (showTrunc) shapeToAddArrow = container.select(CLASS_NAMES.lineSecond.class); - else shapeToAddArrow = container.select(CLASS_NAMES.line.class); + if (type === 'arc') { + shapeToAddArrow = container.select(CLASS_NAMES.line.class); + } else if (showTrunc) { + shapeToAddArrow = container.select(CLASS_NAMES.lineSecond.class); + } else { + shapeToAddArrow = container.select(CLASS_NAMES.line.class); + } if (!showArrow || !lineArrow || (attr.type === 'arc' && isCircle(attr.startAngle, attr.endAngle))) { const node = shapeToAddArrow.node(); if (node) node.style.markerEnd = undefined; diff --git a/src/ui/axis/guides/ticks.ts b/src/ui/axis/guides/ticks.ts index a991b2b5..fc9150f9 100644 --- a/src/ui/axis/guides/ticks.ts +++ b/src/ui/axis/guides/ticks.ts @@ -8,6 +8,8 @@ import { CLASS_NAMES } from '../constant'; import type { AxisDatum, AxisTickStyleProps, RequiredAxisStyleProps } from '../types'; import { getValuePos } from './line'; import { filterExec, getCallbackStyle, getDirectionVector } from './utils'; +import { applyClassName } from '../utils/classname'; +import { CLASSNAME_SUFFIX_MAP } from '../classname-map'; type RequiredAxisTickStyleProps = Required; @@ -42,11 +44,14 @@ function createTickEl( data: AxisDatum[], attr: RequiredAxisStyleProps ) { - const { tickFormatter: formatter } = attr; + const { tickFormatter: formatter, classNamePrefix } = attr; const tickVector = getTickVector(datum.value, attr); let el: any = 'line'; if (isFunction(formatter)) el = () => getCallbackValue(formatter, [datum, index, data, tickVector]); - return container.append(el).attr('className', CLASS_NAMES.tickItem.name); + + const tick = container.append(el).attr('className', CLASS_NAMES.tickItem.name); + applyClassName(tick, CLASS_NAMES.tickItem, CLASSNAME_SUFFIX_MAP.tickItem, classNamePrefix); + return tick; } function applyTickStyle( @@ -86,19 +91,24 @@ export function renderTicks( attr: RequiredAxisStyleProps, animate: StandardAnimationOption ) { + const { classNamePrefix } = attr; const finalData = filterExec(axisData, attr.tickFilter); const tickAttr = subStyleProps(attr, 'tick'); + return container .selectAll(CLASS_NAMES.tick.class) .data(finalData, (d) => d.id || d.label) .join( - (enter) => - enter + (enter) => { + const ticks = enter .append('g') .attr('className', CLASS_NAMES.tick.name) .transition(function (datum: AxisDatum, index: number) { return createTick.call(this, datum, index, finalData, attr, tickAttr, false); - }), + }); + applyClassName(ticks, CLASS_NAMES.tick, CLASSNAME_SUFFIX_MAP.tick, classNamePrefix); + return ticks; + }, (update) => update.transition(function (datum: AxisDatum, index: number) { this.removeChildren(); diff --git a/src/ui/axis/guides/title.ts b/src/ui/axis/guides/title.ts index 3607c5c2..dbfdc1af 100644 --- a/src/ui/axis/guides/title.ts +++ b/src/ui/axis/guides/title.ts @@ -15,6 +15,8 @@ import { } from '../../../util'; import { CLASS_NAMES } from '../constant'; import type { RequiredAxisStyleProps } from '../types'; +import { applyClassName } from '../utils/classname'; +import { CLASSNAME_SUFFIX_MAP } from '../classname-map'; function getTitlePosition( mainGroup: Selection, @@ -101,7 +103,8 @@ export function renderTitle( attr: RequiredAxisStyleProps, animate: StandardAnimationOption ) { - const { titleText } = attr; + const { titleText, classNamePrefix } = attr; + return container .selectAll(CLASS_NAMES.title.class) .data( @@ -109,13 +112,16 @@ export function renderTitle( (d, i) => d.title ) .join( - (enter) => - enter + (enter) => { + const titles = enter .append(() => renderExtDo(titleText)) .attr('className', CLASS_NAMES.title.name) .transition(function () { return applyTitleStyle(select(this), container, axis, attr, animate.enter); - }), + }); + applyClassName(titles, CLASS_NAMES.title, CLASSNAME_SUFFIX_MAP.title, classNamePrefix); + return titles; + }, (update) => update.transition(function () { return applyTitleStyle(select(this), container, axis, attr, animate.update); diff --git a/src/ui/axis/types.ts b/src/ui/axis/types.ts index 33e130c6..223aa089 100644 --- a/src/ui/axis/types.ts +++ b/src/ui/axis/types.ts @@ -183,6 +183,7 @@ export type AxisBaseStyleProps = PrefixStyleProps, showTick?: boolean; showTitle?: boolean; showTrunc?: boolean; + classNamePrefix?: string; }; export type LinearAxisStyleProps = AxisBaseStyleProps & { type: 'linear'; startPos: Vector2; endPos: Vector2 }; diff --git a/src/ui/axis/utils/classname.ts b/src/ui/axis/utils/classname.ts new file mode 100644 index 00000000..586c477b --- /dev/null +++ b/src/ui/axis/utils/classname.ts @@ -0,0 +1,62 @@ +/** + * Generate axis className with optional prefix + * @param baseClassName Base className (e.g., 'axis-line') + * @param suffix Suffix (e.g., 'line') + * @param classNamePrefix User-defined prefix (e.g., 'g2-') + * @returns Concatenated className + * + * @example + * getAxisClassName('axis-line', 'line', 'g2-') + * // => 'axis-line g2-axis-line' + * + * getAxisClassName('axis-line', 'line', undefined) + * // => 'axis-line' + */ +export function getAxisClassName(baseClassName: string, suffix: string, classNamePrefix?: string): string { + if (!classNamePrefix) return baseClassName; + return `${baseClassName} ${classNamePrefix}axis-${suffix}`; +} + +/** + * Extract className from component attributes + * @param attributes Component attributes + * @param baseClassName Base className object + * @param suffix Suffix string + * @returns Concatenated className + */ +export function getClassNameFromAttrs( + attributes: { classNamePrefix?: string }, + baseClassName: { name: string }, + suffix: string +): string { + const { classNamePrefix = '' } = attributes; + return getAxisClassName(baseClassName.name, suffix, classNamePrefix); +} + +/** + * Apply full className (with prefix) to a selection element + * This is a helper to implement the two-step pattern: + * 1. Create element with base className + * 2. Apply full className with prefix + * + * @param selection The selection to apply className to + * @param baseClassName Base className object from CLASS_NAMES + * @param suffix Suffix from CLASSNAME_SUFFIX_MAP + * @param classNamePrefix Optional user-defined prefix + * @returns The selection for chaining + * + * @example + * const element = container.maybeAppendByClassName(CLASS_NAMES.grid, 'g'); + * applyClassName(element, CLASS_NAMES.grid, CLASSNAME_SUFFIX_MAP.grid, classNamePrefix); + */ +export function applyClassName( + selection: any, + baseClassName: { name: string }, + suffix: string, + classNamePrefix?: string +): any { + if (classNamePrefix) { + selection.attr('className', getAxisClassName(baseClassName.name, suffix, classNamePrefix)); + } + return selection; +} diff --git a/src/ui/axis/utils/index.ts b/src/ui/axis/utils/index.ts index 779416a4..72631785 100644 --- a/src/ui/axis/utils/index.ts +++ b/src/ui/axis/utils/index.ts @@ -1,6 +1,7 @@ import type { VerticalFactor, Direction } from '../types'; export * from './test'; +export * from './classname'; export function getFactor(...args: Direction[]): VerticalFactor { const fn = (str: (typeof args)[number]): VerticalFactor => (str === 'positive' ? -1 : 1); diff --git a/src/ui/legend/category.ts b/src/ui/legend/category.ts index e903540b..d1313650 100644 --- a/src/ui/legend/category.ts +++ b/src/ui/legend/category.ts @@ -84,8 +84,17 @@ export class Category extends Component { } render(attributes: Required, container: Group) { - const { width, height, x = 0, y = 0 } = this.attributes; + const { width, height, x = 0, y = 0, classNamePrefix } = this.attributes; const ctn = select(container); + + // Set root container className + const baseClassName = container.className || 'legend-category'; + if (classNamePrefix) { + container.attr('className', `${baseClassName} ${classNamePrefix}legend`); + } else if (!container.className) { + container.attr('className', 'legend-category'); + } + container.style.transform = `translate(${x}, ${y})`; this.renderTitle(ctn, width!, height!); diff --git a/src/ui/legend/continuous.ts b/src/ui/legend/continuous.ts index f71c6977..22710518 100644 --- a/src/ui/legend/continuous.ts +++ b/src/ui/legend/continuous.ts @@ -77,6 +77,15 @@ export class Continuous extends Component { } public render(attributes: Required, container: Group) { + // Set root container className + const { classNamePrefix } = attributes; + const baseClassName = container.className || 'legend-continuous'; + if (classNamePrefix) { + container.attr('className', `${baseClassName} ${classNamePrefix}legend`); + } else if (!container.className) { + container.attr('className', 'legend-continuous'); + } + // 渲染顺序 // 1. 绘制 title, 获得可用空间 // 2. 绘制 label, handle