teableio/teable

View on GitHub
packages/sdk/src/components/grid/renderers/base-renderer/baseRenderer.ts

Summary

Maintainability
F
3 days
Test Coverage
import { LRUCache } from 'lru-cache';
import { parseToRGB } from '../../utils';
import type {
  ILineProps,
  IMultiLineTextProps,
  IRectProps,
  IRoundPolyProps,
  ISingleLineTextProps,
  IVector,
  IPoint,
  ICheckboxProps,
  IRingProps,
  IProcessBarProps,
  IChartLineProps,
  IChartBarProps,
  ITextInfo,
  IAvatarProps,
} from './interface';

const singleLineTextInfoCache: LRUCache<string, { text: string; width: number }> = new LRUCache({
  max: 1000,
});

const multiLineTextInfoCache: LRUCache<string, ITextInfo[]> = new LRUCache({ max: 1000 });

// eslint-disable-next-line sonarjs/cognitive-complexity
export const drawMultiLineText = (ctx: CanvasRenderingContext2D, props: IMultiLineTextProps) => {
  const {
    x = 0,
    y = 0,
    text,
    maxWidth,
    maxLines,
    isUnderline,
    fontSize = 13,
    lineHeight = 22,
    fill = 'black',
    textAlign = 'left',
    verticalAlign = 'middle',
    needRender = true,
  } = props;

  let lines: ITextInfo[] = [];
  const ellipsis = '...';
  const ellipsisWidth = ctx.measureText(ellipsis).width;
  let currentLine = '';
  let currentLineWidth = 0;

  const cacheKey = `${text}-${fontSize}-${maxWidth}-${maxLines}`;
  const cachedLines = multiLineTextInfoCache.get(cacheKey);

  if (cachedLines) {
    lines = cachedLines;
  } else {
    for (let i = 0; i < text.length; i++) {
      const char = text[i];

      if (char === '\n') {
        if (lines.length + 1 === maxLines && i < text.length - 1) {
          lines.push({ text: currentLine + ellipsis, width: currentLineWidth + ellipsisWidth });
          currentLine = '';
          currentLineWidth = 0;
          break;
        }
        lines.push({ text: currentLine, width: currentLineWidth });
        currentLine = '';
        currentLineWidth = 0;
        continue;
      }

      const charWidth = ctx.measureText(char).width;

      if (currentLineWidth + charWidth > maxWidth) {
        if (lines.length < maxLines - 1) {
          lines.push({ text: currentLine, width: currentLineWidth });
          currentLine = char;
          currentLineWidth = charWidth;
        } else {
          if (currentLineWidth + ellipsisWidth > maxWidth) {
            let tempLine = currentLine;
            let tempLineWidth = currentLineWidth;
            while (tempLineWidth + ellipsisWidth > maxWidth) {
              tempLine = tempLine.substring(0, tempLine.length - 1);
              tempLineWidth -= ctx.measureText(tempLine[tempLine.length - 1]).width;
            }
            currentLine = tempLine;
            currentLineWidth = tempLineWidth;
          }
          lines.push({ text: currentLine + ellipsis, width: currentLineWidth + ellipsisWidth });
          break;
        }
      } else {
        currentLine += char;
        currentLineWidth += charWidth;
      }
    }

    if (lines.length < maxLines && currentLine !== '') {
      lines.push({ text: currentLine, width: currentLineWidth });
    }

    multiLineTextInfoCache.set(cacheKey, lines);
  }

  const offsetY = verticalAlign === 'middle' ? fontSize / 2 : 0;

  if (needRender) {
    if (fill) {
      ctx.fillStyle = fill;
      ctx.strokeStyle = fill;
    }
    ctx.textAlign = textAlign;
    ctx.textBaseline = verticalAlign;

    for (let j = 0; j < lines.length; j++) {
      ctx.fillText(lines[j].text, x, y + j * lineHeight + offsetY);
      if (isUnderline) {
        const textWidth = ctx.measureText(lines[j].text).width;
        ctx.beginPath();
        ctx.moveTo(x, y + j * lineHeight + fontSize - 1);
        ctx.lineTo(x + textWidth, y + j * lineHeight + fontSize - 1);
        ctx.stroke();
      }
    }
  }

  return lines;
};

// eslint-disable-next-line sonarjs/cognitive-complexity
export const drawSingleLineText = (ctx: CanvasRenderingContext2D, props: ISingleLineTextProps) => {
  const {
    x = 0,
    y = 0,
    text,
    fill,
    fontSize = 13,
    textAlign = 'left',
    verticalAlign = 'middle',
    maxWidth = Infinity,
    needRender = true,
    isUnderline = false,
  } = props;

  let width = 0;
  let displayText = '';

  const cacheKey = `${text}-${fontSize}-${maxWidth}`;
  const cachedTextInfo = singleLineTextInfoCache.get(cacheKey);

  if (cachedTextInfo) {
    width = cachedTextInfo.width;
    displayText = cachedTextInfo.text;
  } else {
    const ellipsis = '...';
    const ellipsisWidth = ctx.measureText(ellipsis).width;

    for (let i = 0; i < text.length; i++) {
      const char = text[i];
      const charWidth = ctx.measureText(char).width;

      if (width + charWidth > maxWidth) break;

      displayText += char;
      width += charWidth;
    }

    const isDisplayEllipsis = displayText.length < text.length;
    if (isDisplayEllipsis) {
      while (width + ellipsisWidth > maxWidth && displayText.length > 0) {
        displayText = displayText.slice(0, -1);
        width -= ctx.measureText(displayText[displayText.length - 1]).width;
      }
      displayText = ctx.direction === 'rtl' ? ellipsis + displayText : displayText + ellipsis;
      width = Math.min(width + ellipsisWidth, maxWidth);
    } else {
      displayText = text;
    }

    singleLineTextInfoCache.set(cacheKey, { text: displayText, width });
  }

  if (needRender) {
    const offsetY = verticalAlign === 'middle' ? fontSize / 2 : 0;
    const finalX = textAlign === 'right' ? x + maxWidth : x;
    if (fill) {
      ctx.fillStyle = fill;
      ctx.strokeStyle = fill;
    }
    ctx.textAlign = textAlign;
    ctx.textBaseline = verticalAlign;
    ctx.fillText(displayText, finalX, y + offsetY);
    if (isUnderline) {
      ctx.beginPath();
      ctx.moveTo(finalX, y + offsetY + fontSize / 2 - 1);
      ctx.lineTo(finalX + width, y + offsetY + fontSize / 2 - 1);
      ctx.stroke();
    }
  }

  return {
    text: displayText,
    width,
  };
};

export const drawLine = (ctx: CanvasRenderingContext2D, props: ILineProps) => {
  const { x, y, points, stroke, lineWidth = 1, closed = false } = props;
  const length = points.length;

  ctx.save();
  ctx.beginPath();
  if (stroke) ctx.strokeStyle = stroke;
  ctx.lineWidth = lineWidth;
  ctx.lineJoin = 'round';
  ctx.translate(x, y);
  ctx.moveTo(points[0], points[1]);

  for (let n = 2; n < length; n += 2) {
    ctx.lineTo(points[n], points[n + 1]);
  }

  if (closed) {
    ctx.closePath();
  }
  ctx.stroke();
  ctx.restore();
};

export const drawRect = (ctx: CanvasRenderingContext2D, props: IRectProps) => {
  const { x, y, width, height, fill, stroke, radius: _radius } = props;

  ctx.beginPath();
  if (fill) ctx.fillStyle = fill;
  if (stroke) ctx.strokeStyle = stroke;

  if (_radius == null) {
    ctx.rect(x, y, width, height);
  } else {
    const radius =
      typeof _radius === 'number'
        ? { tl: _radius, tr: _radius, br: _radius, bl: _radius }
        : {
            tl: Math.min(_radius.tl, height / 2, width / 2),
            tr: Math.min(_radius.tr, height / 2, width / 2),
            bl: Math.min(_radius.bl, height / 2, width / 2),
            br: Math.min(_radius.br, height / 2, width / 2),
          };

    ctx.moveTo(x + radius.tl, y);
    ctx.arcTo(x + width, y, x + width, y + radius.tr, radius.tr);
    ctx.arcTo(x + width, y + height, x + width - radius.br, y + height, radius.br);
    ctx.arcTo(x, y + height, x, y + height - radius.bl, radius.bl);
    ctx.arcTo(x, y, x + radius.tl, y, radius.tl);
  }
  ctx.closePath();

  if (fill) ctx.fill();
  if (stroke) ctx.stroke();
};

// eslint-disable-next-line sonarjs/cognitive-complexity
export const drawRoundPoly = (ctx: CanvasRenderingContext2D, props: IRoundPolyProps) => {
  const { points, radiusAll, fill, stroke } = props;
  const asVec = function (p: IPoint, pp: IPoint): IVector {
    const vx = pp.x - p.x;
    const vy = pp.y - p.y;
    const vlen = Math.sqrt(vx * vx + vy * vy);
    const vnx = vx / vlen;
    const vny = vy / vlen;
    return {
      x: vx,
      y: pp.y - p.y,
      len: vlen,
      nx: vnx,
      ny: vny,
      ang: Math.atan2(vny, vnx),
    };
  };
  let radius: number;
  const len = points.length;
  let p1 = points[len - 1];

  ctx.beginPath();
  if (fill) ctx.fillStyle = fill;
  if (stroke) ctx.strokeStyle = stroke;
  for (let i = 0; i < len; i++) {
    let p2 = points[i % len];
    const p3 = points[(i + 1) % len];

    const v1 = asVec(p2, p1);
    const v2 = asVec(p2, p3);
    const sinA = v1.nx * v2.ny - v1.ny * v2.nx;
    const sinA90 = v1.nx * v2.nx - v1.ny * -v2.ny;
    let angle = Math.asin(sinA < -1 ? -1 : sinA > 1 ? 1 : sinA);
    let radDirection = 1;
    let drawDirection = false;
    if (sinA90 < 0) {
      if (angle < 0) {
        angle = Math.PI + angle;
      } else {
        angle = Math.PI - angle;
        radDirection = -1;
        drawDirection = true;
      }
    } else {
      if (angle > 0) {
        radDirection = -1;
        drawDirection = true;
      }
    }
    radius = p2.radius !== undefined ? p2.radius : radiusAll;

    const halfAngle = angle / 2;

    let lenOut = Math.abs((Math.cos(halfAngle) * radius) / Math.sin(halfAngle));

    let cRadius: number;
    if (lenOut > Math.min(v1.len / 2, v2.len / 2)) {
      lenOut = Math.min(v1.len / 2, v2.len / 2);
      cRadius = Math.abs((lenOut * Math.sin(halfAngle)) / Math.cos(halfAngle));
    } else {
      cRadius = radius;
    }

    let x = p2.x + v2.nx * lenOut;
    let y = p2.y + v2.ny * lenOut;

    x += -v2.ny * cRadius * radDirection;
    y += v2.nx * cRadius * radDirection;

    ctx.arc(
      x,
      y,
      cRadius,
      v1.ang + (Math.PI / 2) * radDirection,
      v2.ang - (Math.PI / 2) * radDirection,
      drawDirection
    );

    p1 = p2;
    p2 = p3;
  }
  ctx.closePath();
  if (fill) ctx.fill();
  if (stroke) ctx.stroke();
};

export const drawCheckbox = (ctx: CanvasRenderingContext2D, props: ICheckboxProps) => {
  const { x, y, size, radius = 4, fill, stroke, isChecked = false } = props;
  const dynamicSize = isChecked ? size : size - 1;

  ctx.beginPath();
  drawRect(ctx, {
    x,
    y,
    width: dynamicSize,
    height: dynamicSize,
    radius,
    fill,
    stroke,
  });

  if (stroke) ctx.strokeStyle = stroke;
  if (isChecked) {
    ctx.save();
    ctx.beginPath();
    ctx.moveTo(x + size / 4.23, y + size / 1.97);
    ctx.lineTo(x + size / 2.42, y + size / 1.44);
    ctx.lineTo(x + size / 1.29, y + size / 3.25);

    ctx.lineJoin = 'round';
    ctx.lineCap = 'round';
    ctx.lineWidth = 1.9;
    ctx.stroke();
    ctx.restore();
  }
};

export const drawRing = (ctx: CanvasRenderingContext2D, props: IRingProps) => {
  const { x, y, radius, lineWidth = 5, value, maxValue, color } = props;
  const startAngle = -Math.PI / 2;
  const angle = value > maxValue ? 2 * Math.PI : (value / maxValue) * 2 * Math.PI;

  ctx.save();

  ctx.lineWidth = lineWidth;
  ctx.strokeStyle = color;
  ctx.globalAlpha = 0.2;

  ctx.beginPath();
  ctx.arc(x, y, radius, 0, 2 * Math.PI);
  ctx.stroke();

  ctx.strokeStyle = color;
  ctx.globalAlpha = 1;
  ctx.beginPath();
  ctx.arc(x, y, radius, startAngle, angle + startAngle);
  ctx.stroke();

  ctx.restore();
};

export const drawProcessBar = (ctx: CanvasRenderingContext2D, props: IProcessBarProps) => {
  const { x, y, width, height, radius = 4, value, maxValue, color } = props;
  const progressWidth = value > maxValue ? width : (value / maxValue) * width;

  ctx.save();

  ctx.fillStyle = color;
  ctx.globalAlpha = 0.2;

  ctx.beginPath();
  drawRect(ctx, { x, y, width, height, radius });
  ctx.fill();

  ctx.save();
  ctx.beginPath();
  ctx.rect(x, y, progressWidth, height);
  ctx.clip();

  ctx.fillStyle = color;
  ctx.globalAlpha = 1;
  drawRect(ctx, { x, y, width, height, radius });
  ctx.fill();
  ctx.restore();

  ctx.restore();
};

export const drawChartLine = (ctx: CanvasRenderingContext2D, props: IChartLineProps) => {
  const {
    x,
    y,
    width,
    height,
    values,
    displayValues = [],
    color,
    axisColor,
    yAxis,
    font,
    hoverX,
    hoverAmount = 0,
  } = props;
  const [minY, maxY] = yAxis ?? [Math.min(...values), Math.max(...values)];
  const delta = maxY - minY === 0 ? 1 : maxY - minY;
  const zeroY = maxY <= 0 ? y : minY >= 0 ? y + height : y + height * (maxY / delta);

  let drawValues = values.map((d) => Math.min(1, Math.max(0, (d - minY) / delta)));

  if (drawValues.length === 1) {
    drawValues = [drawValues[0], drawValues[0]];
  }

  if (minY <= 0 && maxY >= 0) {
    ctx.beginPath();
    ctx.moveTo(x, zeroY);
    ctx.lineTo(x + width, zeroY);

    ctx.globalAlpha = 0.4;
    ctx.lineWidth = 1;
    ctx.strokeStyle = axisColor;
    ctx.stroke();
    ctx.globalAlpha = 1;
  }

  ctx.beginPath();

  const xStep = width / (drawValues.length - 1);
  const points = drawValues.map((val, index) => {
    return {
      x: x + xStep * index,
      y: y + height - val * height,
    };
  });

  if (points.length > 2) {
    ctx.moveTo(points[0].x, points[0].y);
    for (let i = 0; i < points.length - 2; i++) {
      const xControl = (points[i].x + points[i + 1].x) / 2;
      const yControl = (points[i].y + points[i + 1].y) / 2;
      ctx.quadraticCurveTo(points[i].x, points[i].y, xControl, yControl);
    }
    const curIndex = points.length - 2;
    ctx.quadraticCurveTo(
      points[curIndex].x,
      points[curIndex].y,
      points[curIndex + 1].x,
      points[curIndex + 1].y
    );
  } else {
    ctx.moveTo(points[0].x, points[0].y);
    ctx.lineTo(points[1].x, points[1].y);
  }

  ctx.strokeStyle = color;
  ctx.lineWidth = 1 + hoverAmount * 0.5;
  ctx.stroke();

  ctx.lineTo(x + width, zeroY);
  ctx.lineTo(x, zeroY);
  ctx.closePath();

  ctx.globalAlpha = 0.2 + 0.2 * hoverAmount;
  const grad = ctx.createLinearGradient(0, y, 0, y + height * 1.4);
  grad.addColorStop(0, color);

  const [r, g, b] = parseToRGB(color);
  grad.addColorStop(1, `rgba(${r}, ${g}, ${b}, 0)`);
  ctx.fillStyle = grad;
  ctx.fill();
  ctx.globalAlpha = 1;

  if (hoverX != null) {
    ctx.beginPath();
    const closest = Math.min(values.length - 1, Math.max(0, Math.round(hoverX / xStep)));
    ctx.moveTo(x + closest * xStep, y);
    ctx.lineTo(x + closest * xStep, y + height);

    ctx.lineWidth = 1;
    ctx.strokeStyle = axisColor;
    ctx.stroke();

    ctx.save();
    ctx.font = font;
    drawSingleLineText(ctx, {
      x,
      y,
      text: displayValues[closest] ?? values[closest],
      fill: axisColor,
    });
    ctx.restore();
  }
};

// eslint-disable-next-line sonarjs/cognitive-complexity
export const drawChartBar = (ctx: CanvasRenderingContext2D, props: IChartBarProps) => {
  const {
    x,
    y,
    width,
    height,
    values,
    displayValues = [],
    color,
    axisColor,
    yAxis,
    font,
    hoverX,
  } = props;

  const barMaxWidth = 8;
  const [originMinY, maxY] = yAxis ?? [Math.min(...values), Math.max(...values)];
  const minY = originMinY > 0 ? 0 : originMinY;
  const delta = maxY - minY === 0 ? 1 : maxY - minY;
  const zeroY = maxY <= 0 ? y : minY >= 0 ? y + height : y + height * (maxY / delta);

  const drawValues = values.map((d) => Math.min(1, Math.max(0, (d - minY) / delta)));

  if (minY <= 0 && maxY >= 0) {
    ctx.beginPath();
    ctx.moveTo(x, zeroY);
    ctx.lineTo(x + width, zeroY);

    ctx.globalAlpha = 0.4;
    ctx.lineWidth = 0.5;
    ctx.strokeStyle = axisColor;
    ctx.stroke();
    ctx.globalAlpha = 1;
  }

  ctx.beginPath();
  const margin = 2;
  const spacing = (drawValues.length - 1) * margin;
  const barWidth = Math.min((width - spacing) / drawValues.length, barMaxWidth);

  let drawX = x;
  for (const val of drawValues) {
    let barY = y + height - val * height;
    barY = barY === zeroY ? zeroY - 0.5 : barY;
    ctx.moveTo(drawX, zeroY);
    ctx.lineTo(drawX + barWidth, zeroY);
    ctx.lineTo(drawX + barWidth, barY);
    ctx.lineTo(drawX, barY);

    drawX += barWidth + margin;
  }
  ctx.fillStyle = color;
  ctx.fill();

  if (hoverX != null && hoverX >= 0) {
    ctx.beginPath();
    const xStep = Math.min(width / drawValues.length, barMaxWidth + margin);
    const closest =
      hoverX > drawX - x - margin
        ? null
        : Math.min(drawValues.length - 1, Math.max(0, Math.floor(hoverX / xStep)));

    if (closest == null) return;

    const finalHoverX = x + closest * xStep + (xStep - margin) / 2;
    ctx.moveTo(finalHoverX, y);
    ctx.lineTo(finalHoverX, y + height);

    ctx.lineWidth = 1;
    ctx.strokeStyle = axisColor;
    ctx.stroke();

    ctx.save();
    ctx.font = font;
    drawSingleLineText(ctx, {
      x,
      y,
      text: displayValues[closest] ?? values[closest],
      fill: axisColor,
    });
    ctx.restore();
  }
};

export const drawAvatar = (ctx: CanvasRenderingContext2D, props: IAvatarProps) => {
  const {
    x,
    y,
    width,
    height,
    fill,
    stroke,
    defaultText,
    textColor,
    img,
    fontSize = 10,
    fontFamily,
  } = props;

  ctx.save();
  ctx.beginPath();

  // wrapper stroke
  if (stroke) ctx.strokeStyle = stroke;
  ctx.arc(x + width / 2, y + height / 2, width / 2, 0, Math.PI * 2, false);

  if (fill) ctx.fillStyle = fill;
  if (fill) ctx.fill();
  if (stroke) ctx.stroke();

  if (img) {
    ctx.clip();
    ctx.drawImage(img, x, y, width, height);
    if (stroke) ctx.stroke();
    ctx.restore();
    return;
  }

  const textAbb = defaultText.slice(0, 1);

  ctx.beginPath();
  if (textColor) ctx.fillStyle = textColor;
  ctx.font = `${fontSize}px ${fontFamily}`;

  drawSingleLineText(ctx, {
    x: x + width / 2,
    y: y + height / 2 - fontSize / 2,
    text: textAbb,
    textAlign: 'center',
    fontSize: fontSize,
  });

  if (fill) ctx.fill();
  if (stroke) ctx.stroke();

  ctx.restore();
};