skip to Main Content

I have this code (in the fiddle the circles overlap, but it works alright on my PC – most likely due to pixel widths being off in the fiddle?): https://codesandbox.io/s/objective-hamilton-zspre8?file=/src/App.vue

TestCircle.vue:

<template>
  <div class="circle" :style="{left: this.left,
                 top: this.top}">
    {{value}}
  </div>
</template>

<script>
export default {
name: "TestCircle",

props:  {
  value: String,
  left: Number,
  top: Number
}
}
</script>

<style scoped>
  .circle  {
    background-color: red;

    min-height: 49px;
    height: 49px;

    min-width: 49px;
    width: 49px;

    border-radius: 20em;
    display: inline;
    padding: 1em;
    position: absolute;

    border: 1px solid black;

  }
</style>

TestComponent.vue:

<template>
  <div id="wrapper" ref="wrapper">

    <TestCircle v-for="(circle, i) in this.circles.length"
                 :value = this.circles[i] :key="i"
                :left="this.coordinates[i].left"
                :top="this.coordinates[i].top"

    />

  </div>
</template>

<script>
import TestCircle from "@/app/test/TestCircle";

export default {
  name: "TestComponent",
  components: {TestCircle},
  data() {
    return {
      circles: ['A', 'B', 'C', 'D', 'E', 'F'],
      coordinates: []
    }
  },

    created() {
    console.log("circles: " + this.circles);

    let wrapperWidth = 500;
    let wrapperHeight = 100;

    let circleWidth = 50;
    let circleHeight = 50;

    for (let i = 0; i < this.circles.length; i++) {
      let circleCoordinates = {};

      let isValid = false;
      do {
        isValid = false;

        circleCoordinates.left = (this.randomInRange(0, wrapperWidth - circleWidth)).toString() + 'px';
        circleCoordinates.top = (this.randomInRange(0, wrapperHeight - circleHeight)).toString() + 'px';
        circleCoordinates.width = 50;
        circleCoordinates.height = 50;

        isValid = !this.overlaps(this.coordinates, i, circleCoordinates);
      } while (!isValid);

      this.coordinates.push(circleCoordinates);
    }
  },

  methods: {
    randomInRange: function(min, max)  {
      return Math.random() * (max - min + 1) + min;
    },

    overlaps(coordinates, size, rectangle)  {
      // Loop through all the rectangles in the coordinates array
      for (let i = 0; i < size; i++) {
        // Create a rectangle object for the other rectangle


        // Check whether the two rectangles overlap using the rectanglesOverlap function
        if (this.rectanglesOverlap(rectangle, coordinates[i])) {
          return true;
        }
      }

      // None of the rectangles overlap
      return false;
    },

    rectanglesOverlap(first, second) {
      console.log("rectanglesOverlap: " + JSON.stringify(first)
          + " and "+ JSON.stringify(second));

      // Determine the coordinates of the edges of both rectangles
      const firstLeft = parseFloat(first.left);
      const firstRight = firstLeft + first.width;
      const firstTop = parseFloat(first.top);
      const firstBottom = firstTop + first.height;

      const secondLeft = parseFloat(second.left);
      const secondRight = secondLeft + second.width;
      const secondTop = parseFloat(second.top);
      const secondBottom = secondTop + second.height;

      // Check if any of the edges of the two rectangles overlap
      if (firstLeft < secondRight &&
          firstRight > secondLeft &&
          firstTop < secondBottom &&
          firstBottom > secondTop) {
        // The rectangles overlap
        console.log("rectanglesOverlap: true");
        return true;
      }

      // The rectangles don't overlap
      console.log("rectanglesOverlap: false");
      return false;
    }
  },

  mounted() {

  }

}
</script>

<style scoped>

  #wrapper  {
    margin: 2em;

    height: 100px;
    min-height: 100px;

    width: 500px;
    min-width: 500px;

    background-color: darkblue;
    position: relative;
  }
</style>

TestPage.vue:

<template>
  <div id="test-page">
    <TestComponent/>
   </div>
</template>

<script>

 import TestComponent from "@/app/test/TestComponent";
 export default {
  name: "TestPage",
  components: {TestComponent },

   async beforeCreate() {

   },

   methods: {

   },
}
</script>

<style scoped>
  #test-page  {
    background-color: yellow;
    width: 50%;

    display: flex;
    justify-content: center;
  }
</style>

When I go to TestPage, I see this:

enter image description here

if I renew the page:

enter image description here

enter image description here

As can be seen, the circles are randomly placed inside the blue div, in a way that the circles don’t overlap.

The width of 500px and height of 100px of the blue div and the height&width of each circle (50px) are hardcoded.

It works fine (at least on my PC), however it doesn’t work with resizing the browser window, or switching to mobile:

enter image description here

I need it to look OK on mobile/smaller browser windows/scales.

However I don’t understand how to approach it. I have an idea that I would need to replace wrapperWidth, wrapperHeight, circleWidth, circleHeight in the created with appropriate values. However in the created() there is no access to the wrapper div, because it doesn’t exist yet. How should I approach it?

It should look something like this on mobile:

enter image description here
So far I’ve been using media queries to make my website responsive:

@media screen and (max-width: 500px) {
  .wrapper {
    max-width: 90% !important;

  }

  .btn  {
    font-size: 0.8em;
   }
}

So assuming the width of the blue wrapper div is controlled by the media queries, same with the width of the circles, how should I approach placing them responsively on the blue div?

Any help is appreciated!

EDIT:

A clear, specific question: How to make it work on mobile-sized screens?

2

Answers


    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/[email protected]/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/[email protected]/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).

    Login or Signup to reply.
  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.

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