likes
comments
collection
share

偷偷给网站写了一个霓虹风格计数器

作者站长头像
站长
· 阅读数 5

偷偷给网站写了一个霓虹风格计数器

阅读原文,体验更佳 👉 www.xiaojun.im/posts/2023-…


有很长一段时间,我都想在博客中集成拟物化的访问计数器用于增加一些趣味性,可是我这网站一开始是纯静态的,没用到任何数据库,所以后边不了了之,但最近我在博客中赋予了一些动态能力,这个想法随之也就又浮现了出来。

这个创意最初来自大佬 Joshua Comeau 开源的 react-retro-hit-counter,但后续我产生了自己的一些想法。

本教程不会涉及任何关于数据库的东西,我假设你已经准备了一个数字,不关心你的数据来源,这里就以 1024 来做演示啦~

认识七段数码管

最初我只想实现一个类似计算器那种数字显示效果,它专业点叫做七段数码管(Seven-segment display),你可以在 wikipedia 上见到具体介绍,它一般长下边这种样子,地球人都见过:

偷偷给网站写了一个霓虹风格计数器

这种形态还是比较好处理的,让我们先实现这个效果,最终要实现的霓虹灯效果也是以此为基础才行。

以下所有组件皆是用 tailwindcss + react 编写,为了教程简练省略了部分代码,具体请阅读源码

SevenSegmentDisplay 组件开发

开发之前让我们先分析该组件有哪些部分构成,它可以拆分为哪些子组件?

  • 入口组件,也就是父组件,我们将它命名为 SevenSegmentDisplay.jsx
  • 数字单元组件,我们将它命名为 Digit.jsx
  • 数字单元的片段,每个数字有 7 个片段,我们将它命名为 Segment.jsx

SevenSegmentDisplay

作为入口组件,它负责接收所有的 props 配置,并且将传入的 value 分解为单个数字后传给 Digit 组件。

import React, { useMemo } from 'react'
import Digit from './Digit'

const SevenSegmentDisplay = props => {
  const {
    value, // 要展示的数字
    minLength = 4, // 最小长度,不足则前补 0
    digitSize = 40, // 数字大小(高度)
    digitSpacing = digitSize / 4, // 数字之间的间距
    segmentThickness = digitSize / 8, // 片段厚度
    segmentSpacing = segmentThickness / 4, // 片段之间的缝隙大小
    segmentActiveColor = '#adb0b8', // 片段激活时候的颜色
    segmentInactiveColor = '#eff1f5', // 片段未激活时候的颜色
    backgroundColor = '#eff1f5', // 背景色
    padding = digitSize / 4, // 整个组件的 padding
    glow = false, // 微光效果,其实就是阴影效果
  } = props

  // 将传入的 number 类型数字转为 string 并且根据 minLength 传入的长度进行前补 0
  const paddedValue = useMemo(() => value.toString().padStart(minLength, '0'), [value, minLength])
  // 将补 0 后的数字转为单个字符
  const individualDigits = useMemo(() => paddedValue.split(''), [paddedValue])

  return (
    <div
      className="inline-flex items-center justify-between"
      style={{ padding, backgroundColor, gap: digitSpacing }}
    >
      {individualDigits.map((digit, idx) => (
        <Digit
          key={idx}
          value={Number(digit)}
          digitSize={digitSize}
          segmentThickness={segmentThickness}
          segmentSpacing={segmentSpacing}
          segmentActiveColor={segmentActiveColor}
          segmentInactiveColor={segmentInactiveColor}
          glow={glow}
        />
      ))}
    </div>
  )
}

export default SevenSegmentDisplay

Digit

一个 Digit 包含 7 个 Segment,通过控制不同 Segment 的点亮状态,便可以模拟数字显示。

import React from 'react'
import Segment from './Segment'

// Segment 排布规则
//
//     A
//  F     B
//     G
//  E     C
//     D
//

const segmentsByValue = {
  [0]: ['a', 'b', 'c', 'd', 'e', 'f'],
  [1]: ['b', 'c'],
  [2]: ['a', 'b', 'g', 'e', 'd'],
  [3]: ['a', 'b', 'g', 'c', 'd'],
  [4]: ['f', 'g', 'b', 'c'],
  [5]: ['a', 'f', 'g', 'c', 'd'],
  [6]: ['a', 'f', 'g', 'c', 'd', 'e'],
  [7]: ['a', 'b', 'c'],
  [8]: ['a', 'b', 'c', 'd', 'e', 'f', 'g'],
  [9]: ['a', 'b', 'c', 'd', 'f', 'g'],
}

const isSegmentActive = (segmentId, value) => segmentsByValue[value].includes(segmentId)

const segments = ['a', 'b', 'c', 'd', 'e', 'f', 'g']

const Digit = props => {
  const { value, digitSize } = props

  return (
    <div className="relative w-6 h-8" style={{ width: digitSize * 0.5, height: digitSize }}>
      {segments.map(segment => (
        <Segment
          key={segment}
          segmentId={segment}
          isActive={isSegmentActive(segment, value)}
          segmentThickness={segmentThickness}
          segmentSpacing={segmentSpacing}
          segmentActiveColor={segmentActiveColor}
          segmentInactiveColor={segmentInactiveColor}
          glow={glow}
        />
      ))}
    </div>
  )
}

export default Digit

Segment

根据 segmentId 以及激活状态用 SVG 渲染出对应的 Segment,这是一个不复杂但是比较繁琐的工作 🤖。

import React, { useMemo } from 'react'
import color from 'color'

const Segment = props => {
  const {
    segmentId,
    isActive,
    digitSize,
    segmentThickness,
    segmentSpacing,
    segmentActiveColor,
    segmentInactiveColor,
    glow,
  } = props
  const halfThickness = segmentThickness / 2
  const width = digitSize * 0.5

  const segments = {
    a: {
      top: 0,
      left: 0,
    },
    b: {
      top: 0,
      left: width,
      transform: 'rotate(90deg)',
      transformOrigin: 'top left',
    },
    c: {
      top: width * 2,
      left: width,
      transform: 'rotate(270deg) scaleY(-1)',
      transformOrigin: 'top left',
    },
    d: {
      top: width * 2,
      left: width,
      transform: 'rotate(180deg)',
      transformOrigin: 'top left',
    },
    e: {
      top: width * 2,
      left: 0,
      transform: 'rotate(270deg)',
      transformOrigin: 'top left',
    },
    f: {
      top: 0,
      left: 0,
      transform: 'rotate(90deg) scaleY(-1)',
      transformOrigin: 'top left',
    },
    g: {
      top: width - halfThickness,
      left: 0,
    },
  }

  // a, d
  const path_ad = `
    M ${segmentSpacing} ${0}
    L ${width - segmentSpacing} 0
    L ${width - segmentThickness - segmentSpacing} ${segmentThickness}
    L ${segmentThickness + segmentSpacing} ${segmentThickness} Z
  `

  // b, c, e, f
  const path_bcef = `
    M ${segmentSpacing} ${0}
    L ${width - halfThickness - segmentSpacing} 0
    L ${width - segmentSpacing} ${halfThickness}
    L ${width - halfThickness - segmentSpacing} ${segmentThickness}
    L ${segmentThickness + segmentSpacing} ${segmentThickness} Z
  `

  // g
  const path_g = `
    M ${halfThickness + segmentSpacing} ${halfThickness}
    L ${segmentThickness + segmentSpacing} 0
    L ${width - segmentThickness - segmentSpacing} 0
    L ${width - halfThickness - segmentSpacing} ${halfThickness}
    L ${width - segmentThickness - segmentSpacing} ${segmentThickness}
    L ${segmentThickness + segmentSpacing} ${segmentThickness} Z
  `

  const d = useMemo(
    () =>
      ({
        a: path_ad,
        b: path_bcef,
        c: path_bcef,
        d: path_ad,
        e: path_bcef,
        f: path_bcef,
        g: path_g,
      }[segmentId]),
    [path_ad, path_bcef, path_g, segmentId],
  )

  return (
    <svg
      className="absolute"
      style={{
        ...segments[segmentId],
        // 此处用到了 color 它可以很方便的对颜色进行调整
        filter:
          isActive && glow
            ? `
                drop-shadow(0 0 ${segmentThickness * 1.5}px ${color(segmentActiveColor).fade(0.25).hexa()})
              `
            : 'none',
        zIndex: isActive ? 1 : 0,
      }}
      width={width}
      height={segmentThickness}
      viewBox={`0 0 ${width} ${segmentThickness}`}
      xmlns="http://www.w3.org/2000/svg"
    >
      <path fill={isActive ? segmentActiveColor : segmentInactiveColor} d={d} />
    </svg>
  )
}

export default Segment

基础效果展示

到此,基础的显示组件已经完成了,让我们测试一下显示效果:

偷偷给网站写了一个霓虹风格计数器

这是它的配置参数 👇

<SevenSegmentDisplay
  value={1024}
  minLength={6}
  digitSize={18}
  digitSpacing={4}
  segmentThickness={2}
  segmentSpacing={0.5}
  segmentActiveColor="#ff5e00"
  segmentInactiveColor="#161616"
  backgroundColor="#0c0c0c"
  padding="10px 14px"
  glow
/>

粗略一看还不错,但这与霓虹效果还相差甚远,因为它看起来有些扁平,边缘过于“锐利”,不够真实,所以接下来的目标是要把它变得更真实拟物一些。

如果你不需要霓虹效果,其实到这一步就足够了 😣,在我的网站中浅色模式也是使用的扁平风格,只有在切换到深色模式才会显示为拟物风格,算是一个小小的彩蛋吧。

霓虹灯效果

先分析一下为什么上边的样式看上去不够真实?

  1. 也许是曝光问题?真实世界中发光物本身相对于它的边缘来说看上去会更亮、更白,并且会稍微模糊一些。
  2. 很多情况下发光源做不到均匀照射到所有地方,所以会产生一片区域亮一片区域稍暗的效果,如果你留意过,很多透字键盘背光灯就是这样。

基于以上两点,接下来就想办法用 CSS 将它模拟的更真实一些。

让我们在 SevenSegmentDisplay 组件的基础上再封装一个 NeonHitCounter 组件。

模拟曝光过度效果

我们可以使用 CSS 中的 backdrop-filter 属性模拟过曝效果。

const NeonHitCounter = () => {
  return (
    <div className="relative">
      <SevenSegmentDisplay
        value={1024}
        minLength={6}
        digitSize={18}
        digitSpacing={4}
        segmentThickness={2}
        segmentSpacing={0.5}
        segmentActiveColor="#ff5e00"
        segmentInactiveColor="#161616"
        backgroundColor="#0c0c0c"
        padding="10px 14px"
        glow
      />
      <div className="absolute inset-0 z-10 backdrop-blur-[0.25px] backdrop-brightness-150 pointer-events-none"></div>
    </div>
  )
}

export default NeonHitCounter

在上边代码中我们新建了一个 div 盖在 SevenSegmentDisplay 上边并使用 badckdrop-filter 使组件变亮变模糊,看上去效果已经好了不少。

偷偷给网站写了一个霓虹风格计数器

模拟亮度不均匀效果

让我们将组件中间部分变得更亮,用于模拟亮度不均匀的效果。我们可以用 radial-gradient 创建一个白色径向渐变盖在它上边,然后通过 mix-blend-mode 来控制混合模式,这里用 overlay 比较合适。

有关 mix-blend-mode 的更多详细介绍你可以参考这篇文章

const NeonHitCounter = () => {
  return (
    <div className="relative">
      <SevenSegmentDisplay
        value={1024}
        minLength={6}
        digitSize={18}
        digitSpacing={4}
        segmentThickness={2}
        segmentSpacing={0.5}
        segmentActiveColor="#ff5e00"
        segmentInactiveColor="#161616"
        backgroundColor="#0c0c0c"
        padding="10px 14px"
        glow
      />
      <div
        className="absolute inset-0 z-10 mix-blend-overlay pointer-events-none"
        style={{
          // 通过 luminosity 获取颜色相对亮度如果一个颜色很亮我们则减少亮度增益
          background: `radial-gradient(rgba(255, 255, 255, ${
            1 - color('#ff5e00').luminosity()
          }), transparent 50%)`,
        }}
      ></div>
      <div className="absolute inset-0 z-10 backdrop-blur-[0.25px] backdrop-brightness-150 pointer-events-none"></div>
    </div>
  )
}

export default NeonHitCounter

在上边代码中又创建了一层 div,它利用 radial-gradient + mix-blend-mode: overlay 实现局部颜色增亮,并且根据颜色相对亮度动态判断增益比例,看起来是不是更真实了 👇

偷偷给网站写了一个霓虹风格计数器

了解相对亮度 👉 developer.mozilla.org/en-US/docs/…

模拟玻璃质感

为了模拟透明玻璃质感,我用 Figma 画了一个 SVG 背景(也可以用 CSS 实现,我偷懒了),另外又用 conic-gradient 实现了 4 颗螺丝效果。

<svg width="76" height="38" viewBox="0 0 76 38" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.68" clip-path="url(#clip0_467_36)">
<rect width="76" height="38" fill="url(#paint0_radial_467_36)"/>
<rect width="76" height="38" fill="white" fill-opacity="0.01"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M-80.0879 0H191.953V272.041H-80.0879V0ZM54.9326 263.211C125.178 263.211 182.124 206.266 182.124 136.021C182.124 65.7744 125.178 8.8291 54.9326 8.8291C-15.3135 8.8291 -72.2588 65.7744 -72.2588 136.021C-72.2588 206.266 -15.3135 263.211 54.9326 263.211Z" fill="url(#paint1_linear_467_36)"/>
</g>
<defs>
<radialGradient id="paint0_radial_467_36" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(38 19) scale(38 19)">
<stop stop-color="white" stop-opacity="0"/>
<stop offset="1" stop-color="white" stop-opacity="0.05"/>
</radialGradient>
<linearGradient id="paint1_linear_467_36" x1="-8.40528" y1="-21.8896" x2="68.8142" y2="-4.89117e-06" gradientUnits="userSpaceOnUse">
<stop offset="0.199944" stop-color="white" stop-opacity="0.26"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<clipPath id="clip0_467_36">
<rect width="76" height="38" fill="white"/>
</clipPath>
</defs>
</svg>
import React from 'react'
import SevenSegmentDisplay from '@/components/SevenSegmentDisplay'
import clsx from 'clsx'
import color from 'color'

const Screw = props => {
  const { className } = props

  return (
    <div
      className={clsx(className, 'w-[5px] h-[5px] rounded-full ring-1 ring-zinc-800')}
      style={{ background: `conic-gradient(#333, #666, #333, #666, #333)` }}
    ></div>
  )
}

const NeonHitCounter = () => {
  return (
    <div className="relative">
      <SevenSegmentDisplay
        value={1024}
        minLength={6}
        digitSize={18}
        digitSpacing={4}
        segmentThickness={2}
        segmentSpacing={0.5}
        segmentActiveColor="#ff5e00"
        segmentInactiveColor="#161616"
        backgroundColor="#0c0c0c"
        padding="10px 14px"
        glow
      />
      <div
        className="absolute inset-0 z-10 mix-blend-overlay pointer-events-none"
        style={{
          background: `radial-gradient(rgba(255, 255, 255, ${
            1 - color('#ff5e00').luminosity()
          }), transparent 50%)`,
        }}
      ></div>
      <div
        className="absolute inset-0 z-10 backdrop-blur-[0.25px] backdrop-brightness-150 pointer-events-none"
        style={{
          backgroundImage: 'url(/hit-counter-glass-cover.svg)',
          backgroundSize: 'cover',
          backgroundPosition: 'center',
          boxShadow: `
            0 0 1px rgba(255, 255, 255, 0.1) inset,
            0 1px 1px rgba(255, 255, 255, 0.1) inset
          `,
        }}
      >
        <Screw className="absolute left-1 top-1 -rotate-45" />
        <Screw className="absolute left-1 bottom-1 rotate-45" />
        <Screw className="absolute right-1 top-1 rotate-45" />
        <Screw className="absolute right-1 bottom-1 -rotate-45" />
      </div>
    </div>
  )
}

export default NeonHitCounter

大功告成 ✨

偷偷给网站写了一个霓虹风格计数器