[Vuejs]-How can I prevent randomly positioned divs from overlapping on small/mobile screens? when elements are created in created() hook

2👍

  1. First of all, when you want to interact with DOM, you shouldn’t use created hook, but mounted hook. The difference is that in mounted the component has been mounted (a.k.a. "added to DOM"). That’s because you need the currently available width in the next step.

  2. Secondly, before you can translate anything into code, you have to define it logically, down to the last single step. Your current code indicates you haven’t done that yet.
    In my estimation, to achieve the desired functionality, you need to define a function which takes in the available width and the number of circles, returning the minimal height of a box with that width which could fit all the circles, regardless of each circle’s position. You’ll need a bit of trigonometry to solve this.
    If you have trouble coming up with the formula, consider asking it on mathematics 1.

  3. Once you have the minimal height formula, what you have should work, provided you fix the overlaps function. The correct way to check if two circles intersect is to compare the distance between their centres with the sum of their radiuses.


If you want to skip asking on maths website and would settle for a rough estimation, here’s a pattern in which the circles are most spread out without actually allowing any extra circles in between 2:

enter image description here

The distance between centres is roughly 3.5 × r and each subsequent row has a height of approx. 3 × r. If they’re more distanced apart, the gaps become big enough to accommodate extra circles.

If I had to solve this problem, I would estimate the minimum height of the box based on this pattern 3, rather than calculate the exact trigonometrical formula. I’d give the first row a height of 3 × r (although it’s actually shorter) to simplify the formula and to make sure the circles would fit even in the unlikely event they position themselves "randomly" in the exact pattern shown above.


1 – you’ll need to ask a non-code related question there. Don’t ask them about Vue or mobile devices, they’ll send you back here, without a minimal height formula, which is what you actually need.

2 – if the first 12 circles are positioned as in the image, the 13th circle would cause the while in your code to loop endlessly

3 – a rough formula for min height would be:

const height = Math.max(
  (3 * radius) * (circles.length / (containerWidth / (3.5 * radius))),
  radius * 3.5
)

which could be written as:

const height = Math.max(
  10.5 * circles.length * radius ** 2 / containerWidth,
  3.5 * radius
)

To make sure it will never loop endlessly, a counter could be set on the while. If it exceeds a certain value (e.g: 100 × for the same circle), you could simply recalculate all circles, but the probability that it would ever freeze is negligible, if at all. Note this guardrail has not been included in the example below.

Here’s a demo using this formula:

const {
  createApp,
  reactive,
  toRefs,
  onMounted,
  onBeforeUnmount,
  watch,
  nextTick,
  computed
} = Vue
const circles = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map((id) => ({ id }))
const useBindExternal = (target, ...args) => {
  onMounted(() => target.addEventListener(...args))
  onBeforeUnmount(() => target.removeEventListener(...args))
}

createApp({
  setup() {
    const { hypot, floor, random, max } = Math
    const randomBetween = (m, M) => floor(random() * (M - m + 1)) + m
    const getDistance = (a, b) => hypot(b.x - a.x, b.y - a.y)
    const state = reactive({
      box: null,
      height: 0,
      width: 0,
      radius: 25,
      circles: [],
      getStyle: (c) => ({
        transform: `translate(${c.x - state.radius}px, ${
          c.y - state.radius
        }px)`,
        width: `${state.radius * 2}px`,
        height: `${state.radius * 2}px`
      })
    })
    const addPosition = (c) => ({
      ...c,
      x: randomBetween(state.radius + 1, state.width - state.radius - 2),
      y: randomBetween(state.radius + 1, state.height - state.radius - 2)
    })
    const positionCircles = (arr = circles) => {
      const out = [...state.circles]
      arr.forEach((circle) => {
        let item = addPosition(circle)
        let intersects = true
        while (intersects) {
          if (
            !out.length ||
            !out.some((c) => getDistance(c, item) <= state.radius * 2)
          ) {
            intersects = false
            out.push(item)
          } else {
            item = addPosition(circle)
          }
        }
      })
      state.circles = out
    }
    const invalidateCircles = () => {
      state.circles = state.circles.filter(
        (c) =>
          c.x < state.width - state.radius - 2 &&
          c.y < state.height - state.radius - 2
      )
      positionCircles(
        circles.filter((c) => !state.circles.map(({ id }) => id).includes(c.id))
      )
    }
    const updateWidth = () => {
      state.width = state.box?.offsetWidth || 0
    }

    useBindExternal(window, 'resize', updateWidth)
    onMounted(updateWidth)
    watch(
      () => state.width,
      (val) =>
        val &&
        ((state.height = max(
          (circles.length * state.radius ** 2 * 10.5) / val,
          3.5 * state.radius
        )),
        (state.circles.length ? invalidateCircles : positionCircles)(),
        nextTick(updateWidth))
    )

    return toRefs(state)
  }
}).mount('#app')
.box {
  position: relative;
  border: 1px solid #f00;
}
.box > * {
  border-radius: 50%;
  background-color: #000;
  position: absolute;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
<script src="https://unpkg.com/vue@3.2.47/dist/vue.global.prod.js"></script>
<div id="app">
  <div ref="box" class="box" :style="{ height: height + 'px'}">
    <div v-for="circle in circles" :key="circle.id" :style="getStyle(circle)">
      {{ circle.id }}
    </div>
  </div>
</div>

Or, if you prefer Options API:

const circles = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map((id) => ({ id }))
const { hypot, floor, random, max } = Math
const randomBetween = (m, M) => floor(random() * (M - m + 1)) + m
const getDistance = (a, b) => hypot(b.x - a.x, b.y - a.y)

Vue.createApp({
  data: () => ({
    height: 0,
    width: 0,
    radius: 25,
    circles: []
  }),
  methods: {
    getStyle(c) {
      return {
        transform: `translate(${c.x - this.radius}px, ${c.y - this.radius}px)`,
        width: `${this.radius * 2}px`,
        height: `${this.radius * 2}px`
      }
    },
    addPosition(c) {
      return {
        ...c,
        x: randomBetween(this.radius + 1, this.width - this.radius - 2),
        y: randomBetween(this.radius + 1, this.height - this.radius - 2)
      }
    },
    positionCircles(arr = circles) {
      const out = [...this.circles]
      arr.forEach((circle) => {
        let item = this.addPosition(circle)
        let intersects = true
        while (intersects) {
          if (
            !out.length ||
            !out.some((c) => getDistance(c, item) <= this.radius * 2)
          ) {
            intersects = false
            out.push(item)
          } else {
            item = this.addPosition(circle)
          }
        }
      })
      this.circles = out
    },
    invalidateCircles() {
      this.circles = this.circles.filter(
        (c) =>
          c.x < this.width - this.radius - 2 &&
          c.y < this.height - this.radius - 2
      )
      this.positionCircles(
        circles.filter((c) => !this.circles.map(({ id }) => id).includes(c.id))
      )
    },
    updateWidth() {
      this.width = this.$refs.box?.offsetWidth
    }
  },
  mounted() {
    window.addEventListener('resize', this.updateWidth)
    this.updateWidth()
  },
  beforeUnmount() {
    window.removeEventListener('resize', this.updateWidth)
  },
  watch: {
    width(val) {
      val &&
        ((this.height = max(
          (circles.length * this.radius ** 2 * 10.5) / val,
          3.5 * this.radius
        )),
        this[`${this.circles.length ? 'invalidate' : 'position'}Circles`](),
        this.$nextTick(this.updateWidth))
    }
  }
}).mount('#app')
.box {
  position: relative;
  border: 1px solid #f00;
}
.box > * {
  border-radius: 50%;
  background-color: #000;
  position: absolute;
  color: white;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
<script src="https://unpkg.com/vue@3.2.47/dist/vue.global.prod.js"></script>
<div id="app">
  <div ref="box" class="box" :style="{ height: height + 'px'}">
    <div v-for="circle in circles" :key="circle.id" :style="getStyle(circle)">
      {{ circle.id }}
    </div>
  </div>
</div>

Note on resize event I’m only recalculating positions of the circles previously placed outside of the current container (I’m keeping the circles which don’t need to move).

👤tao

1👍

Given that you would like to randomly place the circles inside a predefined area, none of the css responsive layout mechanisms would be able to help you. You just have to manually design the mathematical algorithm to calculate the coordinates.

That being said, your problem can be defined as calculating the coordinates of a number of circles within the given bounds so that they do not overlap:

getCoordinates(numberOfCircles, circleSize, areaWidth, areaHeight)

And assuming you do have the algorithm (looks like you do, although very brute force), the only problem left is to know the actual areaWidth and areaHeight (instead of hardcoding them), and to know when they change (so that you can re-run your algorithm).

You can use Vue’s template ref to gain access to your container div (Note template refs are not going to be populated until at least the mounted hook), and then you can use ResizeObserver to observe the size change of that div. Every time the size changes, you update the coordinates by running your getCoordinates method again and Vue will take care of the rest.

Leave a comment