Hotel Range
Here is a plausible use case, showing a slider to choose a price range for hotels and showing the number of hotels for each price in the range, plus the total number of hotels.
First of all we are bucketing the hotels into price ranges (e.g. $0-$10, $10-$20, etc.), and
then we are creating a <RangeSlider>
with a step
that matches the bucket size.
We are using the formatter()
prop to add a special label for each pip that sets a --h
custom property
to match the number of hotels in each bucket. Then CSS is applied to style the height of the label to match the number of hotels.
<script>
let values = [ 200, 400 ];
const bucketSize = 10;
const minPrice = 40;
const maxPrice = 800;
// Generate random number of hotels
// (would normally be fetched from an API)
const numHotels = Math.floor(Math.random() * 300) + 900;
// Generate imaginary hotels with prices from skewed normal distribution
// (would normally be fetched from an API)
const hotels = Array.from({ length: numHotels }, () => {
const sampledPrice = sampleSkewedNormal({
min: minPrice,
max: maxPrice,
mean: 200,
stdDev: 120,
skewness: 0.7,
});
return { price: sampledPrice };
});
// Create price buckets based on the bucket size
const priceBuckets = Array.from({ length: 81 }, (_, i) => {
const bucketMin = i * bucketSize;
const bucketMax = bucketMin + bucketSize;
// Count hotels in this price range
const hotelsInBucket = hotels.filter((hotel) => hotel.price >= bucketMin && hotel.price <= bucketMax).length;
return {
priceRange: `${bucketMin}-${bucketMax}`,
minPrice: bucketMin,
maxPrice: bucketMax,
numHotels: hotelsInBucket,
};
});
// Add a span with the number of hotels for a given bucket,
// and style the height (--h) of the span to match the number of hotels
const formatter = (value, index) => {
const hotelCount = priceBuckets[index].numHotels;
const min = priceBuckets[index].minPrice;
const max = priceBuckets[index].maxPrice;
return `
<span class='hotel-range-label' style='--h:${hotelCount}'>
<span class='hotel-range-label-number'>
${hotelCount} hotels @ $${min}-$${max}
</span>
</span>
`;
};
// Format the number of hotels between a given range
const rangeFormatter = (valueMin, valueMax) => {
const hotelsInRange = priceBuckets.filter((b) => b.minPrice >= valueMin).filter((b) => b.minPrice <= valueMax);
const hotelCount = hotelsInRange.reduce((acc, b) => acc + b.numHotels, 0);
return `${hotelCount} hotels`;
};
// Format the value of the handle
const handleFormatter = (value) => {
return `$${value}`;
};
</script>
<div class="container">
<RangeSlider
id="hotel-range"
bind:values
min={0}
max={maxPrice}
step={bucketSize}
pipstep={1}
pips
range
rangeFloat
float
draggy
all="label"
{formatter}
{rangeFormatter}
{handleFormatter}
/>
<div class="fields">
<div class="field">
<label for="min-price">Min price</label>
<input type="number" id="min-price" bind:value={values[0]} step={bucketSize} />
</div>
<hr>
<div class="field">
<label for="max-price">Max price</label>
<input type="number" id="max-price" bind:value={values[1]} step={bucketSize} />
</div>
</div>
<p>There are <strong>{rangeFormatter(values[0], values[1])}</strong> available
between <strong>${values[0]}</strong> and <strong>${values[1]}</strong>.</p>
</div>
.container {
container-type: inline-size;
p {
margin-top: 1em;
}
}
#hotel-range {
--h: 0;
--max-h: 120px;
--shadow: rgba(50, 50, 93, 0.25) 0px 2px 5px -1px, rgba(0, 0, 0, 0.3) 0px 1px 3px -1px;
--range-slider: hsl(269, 10%, 80%);
--range-pip: hsl(269, 10%, 80%);
--range-pip-active: hsl(269, 60%, 58%);
--range-pip-in-range: var(--range-pip-active);
--range-range: var(--range-pip-active);
--range-range-inactive: hsl(269, 10%, 55%);
--range-handle-focus: var(--range-pip-active);
--range-handle: var(--range-pip);
margin-top: calc(var(--max-h) + 20px);
font-size: 14px;
&.rangeSlider,
& .rangeBar {
height: 6px;
}
& .rangePips {
top: auto;
bottom: 100%;
}
& .rsPip {
width: 1px;
height: 0;
bottom: 0;
top: auto;
@container (min-width: 400px) {
width: 3px;
translate: -1.5px 0;
}
@container (min-width: 600px) {
width: 4px;
translate: -2px 0;
}
}
& .rsPip:hover {
z-index: 99;
}
& .rsPipVal {
top: 0;
width: 100%;
background-color: inherit;
}
& .rangeNub {
border-radius: 100%;
box-shadow: inset 0 0 0 4px white, var(--shadow);
}
& .rangeFloat {
bottom: auto;
top: 26px;
box-shadow: var(--shadow);
}
& .rangeBar .rangeFloat {
top: -26px;
}
& .hotel-range-label {
display: block;
height: calc(min((var(--h) * 2px) + min(var(--h) * 4px, 4px), var(--max-h)));
background-color: inherit;
background-image: linear-gradient(0deg, hsla(0, 0%, 100%, 0.5), transparent 30%, hsla(0, 0%, 0%, 0.4) 80px);
position: absolute;
bottom: 0;
left: 0;
border-radius: 8px 8px 0 0;
width: 100%;
}
& .hotel-range-label-number {
opacity: 0;
transition: opacity 0.15s ease-in-out;
font-weight: 600;
pointer-events: none;
position: absolute;
top: 0;
translate: -25% -120%;
text-shadow:
0 1px 1px rgba(255, 255, 255, 1),
0 0 2px rgba(255, 255, 255, 1),
0 1px 4px rgba(255, 255, 255, 1);
background: var(--range-pip);
border-radius: 5px;
padding-inline: 0.5em;
box-shadow: var(--shadow);
}
& .hotel-range-label:hover .hotel-range-label-number {
opacity: 1;
}
}
.fields {
display: grid;
grid-template-columns: 1fr;
gap: 1em;
margin-top: 1em;
margin-inline: auto;
max-width: 520px;
@container (min-width: 500px) {
grid-template-columns: 1fr 24px 1fr;
}
& .field {
display: grid;
grid-template-rows: auto 1fr;
flex: 1;
margin: 0!important;
& input {
grid-row: 2;
grid-column: 1;
appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
appearance: none;
margin: 0;
}
}
&:after {
content: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 320 512'%3E%3Cpath fill='lightSlateGrey' d='M136 24c0-13.3 10.7-24 24-24s24 10.7 24 24v40h56c17.7 0 32 14.3 32 32s-14.3 32-32 32H125.1c-24.9 0-45.1 20.2-45.1 45.1c0 22.5 16.5 41.5 38.7 44.7l91.6 13.1c53.8 7.7 93.7 53.7 93.7 108c0 60.3-48.9 109.1-109.1 109.1H184v40c0 13.3-10.7 24-24 24s-24-10.7-24-24v-40H64c-17.7 0-32-14.3-32-32s14.3-32 32-32h130.9c24.9 0 45.1-20.2 45.1-45.1c0-22.5-16.5-41.5-38.7-44.7l-91.6-13.1c-53.8-7.6-93.7-53.7-93.7-108C16 112.9 64.9 64 125.1 64H136z'/%3E%3C/svg%3E");
grid-row: 2;
grid-column: 1;
justify-self: flex-end;
align-self: center;
text-align: center;
width: 2.2em;
height: 1.6em;
line-height: 2em;
border-left: 1px solid hsl(222, 20%, 75%);
}
}
& hr {
width: 100%;
height: 1px;
background: hsl(222, 20%, 75%);
border: none;
justify-self: center;
align-self: end;
margin: 0 0 21px;
display: none;
opacity: 1;
@container (min-width: 500px) {
display: block;
}
}
& input {
width: 100%;
}
}
/**
* Helper functions to generate random hotel prices across a distribution.
* Normally this data would be fetched from an API.
*/
/**
* Generate a random value from a skewed normal distribution.
* @param mean - The mean of the distribution.
* @param stdDev - The standard deviation of the distribution.
* @param skewness - The skewness of the distribution.
* @returns A random value from the skewed normal distribution.
*/
export function skewedNormalRandom(mean: number, stdDev: number, skewness: number): number {
// Generate random values, ensuring they're not too close to 0
const u = Math.max(1e-10, Math.random());
const v = Math.random(); // Can safely go up to (but not including) 1
// Box-Muller transform
const z = Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
// Apply skewness
// - positive skewness means longer tail to the right
// - negative skewness means longer tail to the left
const skewedZ = z + (skewness * (z * z - 1)) / 6;
return mean + skewedZ * stdDev;
}
/**
* Options for the skewed normal distribution.
* @param min - The minimum value of the distribution.
* @param max - The maximum value of the distribution.
* @param mean - The mean of the distribution.
* @param stdDev - The standard deviation of the distribution.
* @param skewness - The skewness of the distribution.
* @param maxAttempts - The maximum number of attempts to sample a value from the distribution.
*/
interface SkewedNormalOptions {
min: number;
max: number;
mean: number;
stdDev: number;
skewness: number;
maxAttempts?: number;
}
/**
* Sample a value from a skewed normal distribution, falling back to a random value if the skewed normal distribution
* doesn't produce a value within the min and max.
* @param options - The options for the skewed normal distribution.
* @returns A value from the skewed normal distribution.
*/
export function sampleSkewedNormal(options: SkewedNormalOptions): number {
const { min, max, mean, stdDev = mean/2, skewness = 0, maxAttempts = 50 } = options;
let value: number;
let attempts: number = 0;
// try to sample a price between min and maxPrice, along the skewed normal distribution
do {
value = Math.round(skewedNormalRandom(mean, stdDev, skewness));
attempts++;
if (value >= min && value <= max) break;
} while (attempts < maxAttempts);
// Fallback if we can't find a price along the skewed normal distribution
if (attempts >= maxAttempts) {
value = Math.floor(Math.random() * (max - min)) + min;
}
return value;
}