skip to Main Content

I have a simple timeline on my website with cards. The cards get placed on the timeline using a simple function. But some of the cards overlap. I want to make it so if a card overlaps it gets put under the original card. And if it still overlaps it gets put above the timeline. But I’m not sure how to implement this correctly.

Here’s some code I have up until now:

— Calculating card positions —

const calculateCardPosition = (card) => {
    const start = -13800000000;
    const end = 0;
    let x = ((card.date - start) / (end - start)) * 100;

    if (x < 10) {
      x = x + 10;
    } else if (x > 90) {
      x = x - 10;
    }

    return { x };
  };

— Timeline section —

<section id="timeline" className="bg-gray-100 dark:bg-gray-800 py-10">
          <div className="container mx-auto h-96 bg-sections-intro-light dark:bg-sections-intro-dark">
            <h2 className="text-3xl font-bold text-center mb-8">Timeline</h2>

            {/* Timeline */}
            <div className="relative">
              {/* Timeline line */}
              <div className="border-2 border-gray-300 dark:border-gray-700 absolute w-full h-0 left-0 top-1/2 transform -translate-y-1/2"></div>

              {/* Timeline cards */}
              <ul className="gap-4 absolute top-0 left-0 w-full">
                {timelineContent.map((item) => (
                  <li
                    id={item.id}
                    key={item.id}
                    className={`bg-white dark:bg-gray-700 rounded-md shadow-md p-2 cursor-pointer ${
                      activeCard === item.date ? "border-2 border-blue-500" : ""
                    } timeline-card`}
                    style={{
                      position: "absolute",
                      left: `${calculateCardPosition(item).x}%`,
                      transform: "translateX(-50%)",
                      width: "150px",
                    }}
                    onClick={() => scrollToSection(item.scrollTo)}
                    onMouseEnter={() => handleCardHover(item.date)}
                    onMouseLeave={() => handleCardHover(null)}
                  >
                    <h3 className="text-base font-semibold">{item.title}</h3>
                    {activeCard === item.date && <p className="mt-2 text-sm">{item.description}</p>}
                  </li>
                ))}
              </ul>
            </div>
          </div>
        </section>

Timeline

2

Answers


  1. Check if two cards overlap

    const checkOverlap = (card1, card2) => {
      const card1Width = 150; 
      const cardGap = 10;
      const card1Position = card1.style.left ? parseInt(card1.style.left, 10) : 0;
      const card2Position = card2.style.left ? parseInt(card2.style.left, 10) : 0;
    
      return Math.abs(card1Position - card2Position) < card1Width + cardGap;
    };
    

    Adjust card positions to avoid overlapping

    const adjustCardPositions = () => {
      const cards = document.getElementsByClassName("timeline-card");
    
      for (let i = 0; i < cards.length; i++) {
        const currentCard = cards[i];
    
        for (let j = i + 1; j < cards.length; j++) {
          const nextCard = cards[j];
    
          if (checkOverlap(currentCard, nextCard)) {
            const currentPosition = parseInt(currentCard.style.left, 10);
            const nextPosition = calculateCardPosition(timelineContent, j);
    
            if (currentPosition <= nextPosition) {
              nextCard.style.left = `${nextPosition + 10}%`;
            } else {
              currentCard.style.left = `${nextPosition - 10}%`;
            }
          }
        }
      }
    };
    window.addEventListener("load", adjustCardPositions);
    window.addEventListener("resize", adjustCardPositions);
    

    Update the calculateCardPosition

    const calculateCardPosition = (cardIndex) => {
      const start = -13800000000;
      const end = 0;
      let y = ((timelineContent[cardIndex].date - start) / (end - start)) * 100;
    
      if (y < 10) {
        y = 10;
      } else if (y > 90) {
        y = 90;
      }
    
      return y;
    };
    

    Styling

    style={{
      position: "absolute",
      left: "50%",
      transform: "translateX(-50%)",
      top: `${calculateCardPosition(cardIndex)}%`,
      width: "150px",
    }}
    
    

    Hope it helps.

    Login or Signup to reply.
  2. Run through the list of cards and calculate if they would overlap by first getting their x values.

    const activeCard = null;
    
    const timelineContent = [
      {
        id: 0,
        title: 'Foo',
        description: 'Description',
        date: -12800000000,
      },
      {
        id: 1,
        title: 'Bar',
        description: 'Description',
        date: -10800000000,
      },
      {
        id: 2,
        title: 'Baz',
        description: 'Description',
        date: -9800000000,
      },
      {
        id: 3,
        title: 'Foo bar',
        description: 'Description',
        date: -800000000,
      },
    ];
    
    const calculateCardPosition = (card) => {
      const start = -13800000000;
      const end = 0;
      let x = ((card.date - start) / (end - start)) * 100;
    
      if (x < 10) {
        x = x + 10;
      } else if (x > 90) {
        x = x - 10;
      }
    
      return { x };
    };
    
    // Build an intermediary array with `x` positions and default `y` positions.
    const timelineContent1 = timelineContent.map(card => ({
      ...card,
      x: calculateCardPosition(card).x,
      y: 0,
    }));
    
    function App() {
      const container = React.useRef(null);
      // Have a state of our transformed timeline content with updated `y` positions.
      const [timelineContent2, setTimelineContent] = React.useState(timelineContent1);
      
      React.useLayoutEffect(() => {
        // Get the current width of the position container, the timeline element.
        const { width } = container.current.getBoundingClientRect();
    
        setTimelineContent(
          // Work through the list. `.slice(1)` since the first item will not need to be
          // altered.
          timelineContent1.slice(1).reduce(
            (accumulator, card, i) => {
              // Get the previous card's `x` and `y`.
              const { x: previousX, y: previousY } = accumulator[i];
              return accumulator.concat({
                ...card,
                // If the percentage × the container width is less than 150, then this card
                // would overlap, so add `3` (arbitrary for this example) to prevent the overlap.
                // Otherwise, use default `0`.
                y: (Math.abs(card.x - previousX) / 100) * width < 150
                  ? previousY + 3
                  : 0
              });
            },
            [timelineContent1[0]]
          )
        );
      }, [setTimelineContent, container]);
    
      return (
        <section id="timeline" className="bg-gray-100 dark:bg-gray-800 py-10">
          <div className="container mx-auto h-96 bg-sections-intro-light dark:bg-sections-intro-dark">
            <h2 className="text-3xl font-bold text-center mb-8">Timeline</h2>
    
            {/* Timeline */}
            <div className="relative" ref={container}>
              {/* Timeline line */}
              <div className="border-2 border-gray-300 dark:border-gray-700 absolute w-full h-0 left-0 top-1/2 transform -translate-y-1/2"></div>
              {/* Timeline cards */}
              <ul className="gap-4 absolute top-0 left-0 w-full">
                {timelineContent2.map((item, i) => (
                  <li
                    id={item.id}
                    key={item.id}
                    className={`bg-white dark:bg-gray-700 rounded-md shadow-md p-2 cursor-pointer ${
                      activeCard === item.date ? 'border-2 border-blue-500' : ''
                    } timeline-card`}
                    style={{
                      position: 'absolute',
                      top: `${item.y || 0}rem`,
                      left: `${item.x}%`,
                      transform: 'translateX(-50%)',
                      width: '150px',
                    }}
                  >
                    <h3 className="text-base font-semibold">{item.title}</h3>
                    {activeCard === item.date && (
                      <p className="mt-2 text-sm">{item.description}</p>
                    )}
                  </li>
                ))}
              </ul>
            </div>
          </div>
        </section>
      );
    }
    ReactDOM.createRoot(document.getElementById('app')).render(<App/>)
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.production.min.js" integrity="sha512-8Q6Y9XnTbOE+JNvjBQwJ2H8S+UV4uA6hiRykhdtIyDYZ2TprdNmWOUaKdGzOhyr4dCyk287OejbPvwl7lrfqrQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.production.min.js" integrity="sha512-MOCpqoRoisCTwJ8vQQiciZv0qcpROCidek3GTFS6KTk2+y7munJIlKCVkFCYY+p3ErYFXCjmFjnfTTRSC1OHWQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
    <script src="https://cdn.tailwindcss.com"></script>
    
    <div id="app"></div>

    This is a proof-of-concept but for production code, you may need to listen for if the timeline container resizes, and recalculate the y positions accordingly. The y offset of 3rem here is arbitrary and is something that might need to be calculated dynamically too in production code.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search