1. 前言
瀑布流布局,是一种比较流行的网站页面布局方式。等宽不等高,后面的元素依次添加到前一行最矮的元素下方。例如淘宝,小红书都采用了瀑布流布局,这里放张本示例效果的截图:
2. 核心思想
-
通过预加载获取图片的宽高(如果服务端能直接返回图片信息更好)
-
第一行开始摆放图片,并将图片的高度存在数组中,例如第一行有两列,高度数组为arr=[100,200]
-
获取数组中最小值100,以及下标0。最小值100就是第三张图片的top。下标0*(图片宽度+padding)就是第三张图片的left。
- 确定第三张图片位置信息的同时,更新高度数组,将第三张的高度加到数组的最小值上,例如这里变为arr = [340,200],然后很明显地4张的top取数组的最小值200,第4张的left为最小值的下标1*(图片宽度200+padding值)如此反复。
3. 数据准备
新建imgs.ts,存放图片信息
js
export default [
{
url: "https://tse2-mm.cn.bing.net/th/id/OIP-C.tJ293_qmktdZiODFiC2sygHaE4?w=277&h=183&c=7&r=0&o=5&pid=1.7",
info: "我是第1张图片",
},
{
url: "https://tse2-mm.cn.bing.net/th/id/OIP-C.319YjT8mAzDbcd83QKl72wHaEo?w=300&h=187&c=7&r=0&o=5&pid=1.7",
info: "我是第2张图片",
},
{
url: "https://tse1-mm.cn.bing.net/th/id/OIP-C.Zte3ljd4g6kqrWWyg-8fhAHaEo?w=272&h=180&c=7&r=0&o=5&pid=1.7",
info: "我是第3张图片",
},
{
url: "https://tse4-mm.cn.bing.net/th/id/OIP-C.5xAgxwljfeS9C5bTx6pQuQHaEo?w=200&h=187&c=7&r=0&o=5&pid=1.7",
info: "我是第4张图片",
},
{
url: "https://tse4-mm.cn.bing.net/th/id/OIP-C.SYm1k3ZdW86JptueyTGPJgHaE7?w=272&h=182&c=7&r=0&o=5&pid=1.7",
info: "我是第5张图片",
},
{
url: "https://tse2-mm.cn.bing.net/th/id/OIP-C.7sAjIeoQYWnXV_QnuYs1jQHaEK?w=302&h=180&c=7&r=0&o=5&pid=1.7",
info: "我是第6张图片",
},
{
url: "https://tse3-mm.cn.bing.net/th/id/OIP-C.r0OnuYkvsbqBrYk3kUT53AHaKX?w=99&h=181&c=7&r=0&o=5&pid=1.7",
info: "我是第7张图片",
},
{
url: "https://tse3-mm.cn.bing.net/th/id/OIP-C.TbL0XKZ2jNF3cpqDF5dnawHaEo?w=286&h=180&c=7&r=0&o=5&pid=1.7",
info: "我是第8张图片",
},
{
url: "https://tse4-mm.cn.bing.net/th/id/OIP-C.QiENtPtG3CIjC6yr0P-bMQHaFj?w=249&h=187&c=7&r=0&o=5&pid=1.7",
info: "我是第9张图片",
},
{
url: "https://tse1-mm.cn.bing.net/th/id/OIP-C.T4oxzDuFmfBHsHX_zBEYkQHaFj?w=166&h=187&c=7&r=0&o=5&pid=1.7",
info: "我是第10张图片",
},
{
url: "https://tse2-mm.cn.bing.net/th/id/OIP-C.PblCAZjGQhdLpHc3AyEjVgHaIG?w=184&h=192&c=7&r=0&o=5&pid=1.7",
info: "我是第11张图片",
},
{
url: "https://tse4-mm.cn.bing.net/th/id/OIP-C.KMv2fG1yzkp1Zrn9BRY6uQHaJc?w=132&h=180&c=7&r=0&o=5&pid=1.7",
info: "我是第12张图片",
},
{
url: "https://tse1-mm.cn.bing.net/th/id/OIP-C.r5nmhiLH9H0gTuRY4cZhiAHaHa?w=165&h=180&c=7&r=0&o=5&pid=1.7",
info: "我是第13张图片",
},
{
url: "https://tse4-mm.cn.bing.net/th/id/OIP-C.fEobO2mQrHIcyp564DolKQHaEK?w=315&h=180&c=7&r=0&o=5&pid=1.7",
info: "我是第14张图片",
},
{
url: "https://tse1-mm.cn.bing.net/th/id/OIP-C.JVWr8GO8VHavMm9yEyVwgQHaFj?w=229&h=180&c=7&r=0&o=5&pid=1.7",
info: "我是第15张图片",
},
{
url: "https://tse1-mm.cn.bing.net/th/id/OIP-C.j_0oZrDZX2B4DNYPqVcOswHaHa?w=172&h=180&c=7&r=0&o=5&pid=1.7",
info: "我是第16张图片",
},
{
url: "https://tse4-mm.cn.bing.net/th/id/OIP-C.byqRULijwzwRE2m3lN8uWwHaLH?w=122&h=184&c=7&r=0&o=5&pid=1.7",
info: "我是第17张图片",
},
{
url: "https://tse1-mm.cn.bing.net/th/id/OIP-C.7BuhSMPYLQqruILu8YFKKQHaH7?w=183&h=195&c=7&r=0&o=5&pid=1.7",
info: "我是第18张图片",
},
{
url: "https://tse1-mm.cn.bing.net/th/id/OIP-C.cCtz3qg4ewqQm1KM9V906wHaFI?w=265&h=183&c=7&r=0&o=5&pid=1.7",
info: "我是第19张图片",
},
];
4. 核心代码分析
1. 图片的宽度+图片的padding 得到每一项小容器的宽度
js
const colWidth = computed(() => props.imgWidth + props.gap);
2. calcuCols 方法用来计算得到列数
外层容器的宽度 / 小容器的宽度 得到布局的列数
js
const calcuCols = () => {
const width = (container.value as HTMLDivElement).offsetWidth;
return Math.max(Math.floor(width / colWidth.value), 2);
};
3. preload 方法用来得到图片高度
使用loadedCount定位已加载图片的数量,防止滚动加载添加数据时,重复计算图片高度
对图片预加载,得到图片高度,为了防止失真,渲染高度_height = 图片宽度 * 图片宽高比
如果图片加载失败,可添加_error:true,渲染失败时图片的占位图
最后如果已加载的数量loadedCount等于数组list的长度,说明数据_height已全部处理好,可以进行渲染了
js
const preload = () => {
const list: Item[] = cloneDeep(props.list);
list.forEach((item, index) => {
if (index < loadedCount.value) return; // 只对新加载图片进行预加载
let oImg = new Image();
oImg.src = item.url;
oImg.onload = oImg.onerror = (e: any) => {
loadedCount.value++;
// 预加载图片,计算图片容器的高
list[index]._height =
e.type === "load"
? Math.round(props.imgWidth * (oImg.height / oImg.width))
: props.imgWidth;
if (e.type == "error") {
list[index]._error = true;
console.log("图片加载失败", list[index]);
}
if (loadedCount.value === list.length) {
//图片_height属性已添加,执行渲染
imgs.value = list;
isFirstLoad.value = false;
nextTick(() => {
loading.value = false;
waterfall(); //在下一个钩子中,控制图片的位置
});
}
};
});
};
4. waterfall 方法用来确定图片的位置,进行布局
确定一个开始索引beginIndex,防止滚动加载时,重复计算图片的位置信息
当索引 i 小于列数时,说明是第一行,图片的top为0,left为索引*每一项小容器的宽度
当索引 i 大于列数时,说明非第一行,取出高度数组中最小值minHeight以及对应的下标minIndex,新图片的top就是minHeight,新图片的left就是minIndex*每一项小容器的宽度。并更新高度数组。
最后排列完后,更新beginIndex的值,下次有新数据添加时,从该下标开始。
js
const waterfall = () => {
const imgBoxEls = (scrollEl.value as HTMLDivElement).children;
let top, left, height;
if (beginIndex.value == 0) colsHeight.value = [];
for (let i = beginIndex.value; i < imgs.value.length; i++) {
height = (imgBoxEls[i] as HTMLDivElement).offsetHeight;
if (i < cols.value) {
//第一排,直接把高塞入数组
colsHeight.value.push(height);
top = 0;
left = i * colWidth.value;
} else {
const minHeight = Math.min(...colsHeight.value); // 最低高低
const minIndex = colsHeight.value.indexOf(minHeight); // 最低高度的索引
top = minHeight;
left = minIndex * colWidth.value;
// 更新colsHeight,元素的高度加到最小高度上
colsHeight.value[minIndex] = minHeight + height;
}
(imgBoxEls[i] as HTMLDivElement).style.left = left + "px";
(imgBoxEls[i] as HTMLDivElement).style.top = top + "px";
}
beginIndex.value = imgs.value.length; // 排列完之后,新增图片从这个索引开始预加载图片和排列
};
5. onScroll 方法用来监听触底事件
loading控制加载状态,如果加载时又触底,可忽略
如果触底触发scrollReachBottom事件,通知父组件添加数据
js
const onScroll = () => {
//如果正在预加载
if (loading.value) return;
const minHeight = Math.min(...colsHeight.value);
if (scrollEl.value) {
if (
scrollEl.value.scrollTop + scrollEl.value.offsetHeight >
minHeight - props.reachBottomDistance
) {
loading.value = true;
emit("scrollReachBottom");
}
}
};
6. onResize 方法用来监听屏幕resize事件
如果resize后,列数不变,则不处理
如果列数变了,则重新获取所有图片的布局信息,并进行排列
js
const onResize = () => {
const old = cols.value;
cols.value = calcCols();
if (old === cols.value) return; // 列数不变直接退出
beginIndex.value = 0; // 开始排列的元素索引
waterfall(); //进行排列
};
5. Waterfall.vue组件完整代码
js
<template>
<div
class="vue-waterfall-container"
ref="container"
:style="{
width,
height,
}"
>
<div
class="loading ball-beat"
v-show="loading"
:class="{ first: isFirstLoad }"
>
<div class="dot" v-for="(_, index) in 3" :key="index"></div>
</div>
<div class="vue-waterfall-scroll" ref="scrollEl">
<div
class="img-box"
v-for="(item, index) in imgs"
:class="['default-card-animation', { __err__: item._error }]"
:key="index"
:style="{
padding: gap / 2 + 'px',
width: colWidth + 'px',
}"
@click="handleClickImage(item)"
>
<div class="cardStyle">
<div
class="img-inner-box"
:style="{
width: imgWidth + 'px',
height: item._height + 'px',
}"
>
<img :src="item.url" />
</div>
<div class="img-box-footer">
<slot :data="item" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ref,
computed,
watch,
nextTick,
onMounted,
onBeforeUnmount,
} from "vue";
import { cloneDeep } from "lodash-es";
type Item = {
url: string;
info: string;
[key: string]: any;
};
const props = withDefaults(
defineProps<{
width?: string;
height?: string;
imgWidth?: number;
gap?: number;
list: Item[];
reachBottomDistance?: number;
}>(),
{
width: "100%",
height: "100%",
imgWidth: 240,
gap: 20,
reachBottomDistance: 20,
}
);
const emit = defineEmits<{
scrollReachBottom: [value?: undefined];
onClick: [value: Item];
}>();
const loading = ref(true); // 正在预加载中,显示加载动画
const isFirstLoad = ref(true); //首次加载
const imgs = ref<Item[]>([]); // 有height字段的图片列表
const cols = ref(0); // 列数
const loadedCount = ref(0); //大于此值为新增图片
const beginIndex = ref(0); // 大于此值为要新排列的图片
const colsHeight = ref<number[]>([]); //每列的高度,用于寻找最小高度
const container = ref<HTMLDivElement | null>(null);
const scrollEl = ref<HTMLDivElement | null>(null);
const colWidth = computed(() => props.imgWidth + props.gap);
const calcCols = () => {
const width = (container.value as HTMLDivElement).offsetWidth;
return Math.max(Math.floor(width / colWidth.value), 2);
};
const preload = () => {
const list: Item[] = cloneDeep(props.list);
list.forEach((item, index) => {
if (index < loadedCount.value) return; // 只对新加载图片进行预加载
let oImg = new Image();
oImg.src = item.url;
oImg.onload = oImg.onerror = (e: any) => {
loadedCount.value++;
// 预加载图片,计算图片容器的高
list[index]._height =
e.type === "load"
? Math.round(props.imgWidth * (oImg.height / oImg.width))
: props.imgWidth;
if (e.type == "error") {
list[index]._error = true;
console.log("图片加载失败", list[index]);
}
if (loadedCount.value === list.length) {
//图片_height属性已添加,执行渲染
imgs.value = list;
isFirstLoad.value = false;
nextTick(() => {
loading.value = false;
waterfall(); //在下一个钩子中,控制图片的位置
});
}
};
});
};
const waterfall = () => {
const imgBoxEls = (scrollEl.value as HTMLDivElement).children;
let top, left, height;
if (beginIndex.value == 0) colsHeight.value = [];
for (let i = beginIndex.value; i < imgs.value.length; i++) {
height = (imgBoxEls[i] as HTMLDivElement).offsetHeight;
if (i < cols.value) {
//第一排,直接把高塞入数组
colsHeight.value.push(height);
top = 0;
left = i * colWidth.value;
} else {
const minHeight = Math.min(...colsHeight.value); // 最低高低
const minIndex = colsHeight.value.indexOf(minHeight); // 最低高度的索引
top = minHeight;
left = minIndex * colWidth.value;
// 更新colsHeight,元素的高度加到最小高度上
colsHeight.value[minIndex] = minHeight + height;
}
(imgBoxEls[i] as HTMLDivElement).style.left = left + "px";
(imgBoxEls[i] as HTMLDivElement).style.top = top + "px";
}
beginIndex.value = imgs.value.length; // 排列完之后,新增图片从这个索引开始预加载图片和排列
};
const onScroll = () => {
//如果正在预加载
if (loading.value) return;
const minHeight = Math.min(...colsHeight.value);
if (scrollEl.value) {
if (
scrollEl.value.scrollTop + scrollEl.value.offsetHeight >
minHeight - props.reachBottomDistance
) {
loading.value = true;
emit("scrollReachBottom");
}
}
};
const onResize = () => {
const old = cols.value;
cols.value = calcCols();
if (old === cols.value) return; // 列数不变直接退出
beginIndex.value = 0; // 开始排列的元素索引
waterfall(); //进行排列
};
const handleClickImage = (value: Item) => {
emit("onClick", value);
};
watch(props.list, preload);
onMounted(() => {
preload();
cols.value = calcCols(); //根据容器宽度和图片宽度,得到列数
window.addEventListener("resize", onResize);
(scrollEl.value as HTMLDivElement).addEventListener("scroll", onScroll);
});
onBeforeUnmount(() => {
window.removeEventListener("resize", onResize);
(scrollEl.value as HTMLDivElement).removeEventListener("scroll", onScroll);
});
</script>
<style lang="scss">
.vue-waterfall-container {
position: relative;
.vue-waterfall-scroll {
position: relative;
width: 100%;
height: 100%;
overflow-x: hidden;
overflow-y: scroll;
}
.img-box {
position: absolute;
box-sizing: border-box;
//卡片出来时的动画
&.default-card-animation {
animation: show-card 0.4s;
transition: left 0.6s, top 0.6s;
transition-delay: 0.1s;
}
@keyframes show-card {
0% {
transform: scale(0.5);
}
100% {
transform: scale(1);
}
}
.cardStyle {
box-shadow: rgba(0, 0, 0, 0.2) 0px 3px 1px -2px,
rgba(0, 0, 0, 0.14) 0px 2px 2px 0px, rgba(0, 0, 0, 0.12) 0px 1px 5px 0px;
border-radius: 4px;
img {
width: 100%;
display: block;
border-radius: 4px 4px 0 0;
}
}
&.__err__ {
.img-inner-box {
background-image: url(data:image/jpeg;base64,/9j/4QAYRXhpZgAASUkqAAgAAAAAAAAAAAAAAP/sABFEdWNreQABAAQAAAAeAAD/4QMraHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wLwA8P3hwYWNrZXQgYmVnaW49Iu+7vyIgaWQ9Ilc1TTBNcENlaGlIenJlU3pOVGN6a2M5ZCI/PiA8eDp4bXBtZXRhIHhtbG5zOng9ImFkb2JlOm5zOm1ldGEvIiB4OnhtcHRrPSJBZG9iZSBYTVAgQ29yZSA1LjMtYzAxMSA2Ni4xNDU2NjEsIDIwMTIvMDIvMDYtMTQ6NTY6MjcgICAgICAgICI+IDxyZGY6UkRGIHhtbG5zOnJkZj0iaHR0cDovL3d3dy53My5vcmcvMTk5OS8wMi8yMi1yZGYtc3ludGF4LW5zIyI+IDxyZGY6RGVzY3JpcHRpb24gcmRmOmFib3V0PSIiIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdFJlZj0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlUmVmIyIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bXBNTTpEb2N1bWVudElEPSJ4bXAuZGlkOjk1M0JCM0QwNkVFNDExRThCNTJCQUQ2RDFGQzg0NzIxIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjk1M0JCM0NGNkVFNDExRThCNTJCQUQ2RDFGQzg0NzIxIiB4bXA6Q3JlYXRvclRvb2w9IkFkb2JlIFBob3Rvc2hvcCBDUzYgKFdpbmRvd3MpIj4gPHhtcE1NOkRlcml2ZWRGcm9tIHN0UmVmOmluc3RhbmNlSUQ9InhtcC5paWQ6QTYwRUMyMDE2RUUzMTFFOEJCRTU5RTFDODg1ODgwMjYiIHN0UmVmOmRvY3VtZW50SUQ9InhtcC5kaWQ6QTYwRUMyMDI2RUUzMTFFOEJCRTU5RTFDODg1ODgwMjYiLz4gPC9yZGY6RGVzY3JpcHRpb24+IDwvcmRmOlJERj4gPC94OnhtcG1ldGE+IDw/eHBhY2tldCBlbmQ9InIiPz7/7gAOQWRvYmUAZMAAAAAB/9sAhAAQCwsLDAsQDAwQFw8NDxcbFBAQFBsfFxcXFxcfHhcaGhoaFx4eIyUnJSMeLy8zMy8vQEBAQEBAQEBAQEBAQEBAAREPDxETERUSEhUUERQRFBoUFhYUGiYaGhwaGiYwIx4eHh4jMCsuJycnLis1NTAwNTVAQD9AQEBAQEBAQEBAQED/wAARCACRAJEDASIAAhEBAxEB/8QAZQAAAwEBAQAAAAAAAAAAAAAAAAIDAQQHAQEAAAAAAAAAAAAAAAAAAAAAEAACAQMDBAEFAAMBAAAAAAAAAQIRMQMhQRJRYYEycZHBIkITsdFSYhEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8A9AAAAAMFnNQWt9kAwkssVbV9CTnKb10XQVtLSyAd5ZuzURW27yfhmX9RlDI+wD4Vf/ZVi41SKCdeNI3YEnOXJtOiBZZr/wBGcMi2Ft7AXjli76PoOcqael0Mpyg9NV0A6QEhNTWl90MBoAAAAAAGGiTlxjXfZAZkycdEqyI3q26sOrd92CTm6bbsA1boh1i3lqPGKiqIZAYklYHY0x6tIDY2B3NdjEBgNJ3NACTxbx0E1TozoYsoqSowI2o06MtjyctGqSItODptsw6NX2YHSaJCXKNd90OAAAAYznnLnJvZWK5pUjRXloiNdwCjk0l9S0YqKohcSpGrvLUcDUBLK23x23FWNtVSAuHch/KXQP5PoBdqq77GJ1XfczFVKjVBcuPk6rXqBQCH8n0D+UugFwZD+bV1oNif5OOzQDyipKjI0cW0/qXYmVVjVXjqAkZcHXZ3OhHNXcthlWNHeOjAoAABDLKs6bISlWl1Busm+42Jfm30At2BmI1gRy+3gpD1XwTy+3gpH1QDASyt8uKdFuJRw1iwOjdBKy+TIutGbK3kAAxtJNuwiywdmA7s/glj9/BV04unQli9l8AWDsBjAhSja6D4pUnTZmZV+afUVOkk+4HUBgAc0bD4v2+fsJGw+L9vn7AVQMEDAjl9vBSHqvgnl9vBSHqvgDJwbfJC8JPSlEO5wTo3qMnUDEqNGz0jXoD08BRSXyBB/m6u2xvFPQ1qjoDAVNxqv1ZuJUnT5BGw9/AFQYAwJZf1+fsJKw+X9fn7CSsB0AAAc7VG13GxP82uoZFSbezFWkkwOgGCBgRy15adDZTaioq7QZPfwZQDFFfPc2MnB0vE1AwCc+WituPjaS4kzU6agPkS9hEVeqJJU0AAh7+ACHv4AqAAwI5X+aXQVKrS7g3WTY2ONZp7IC9AAAJ5lWNf+dSV1XqdL1VGc8lxk47bAUxyqqO60GIpuL5LyuxZNNVVtmAmVfkpbbi1RYAI1QVRYAI1QJ1aRYzdAbJ8Y1I1RZggI1SNxL8uW1NCoAYxckqKiu9Bm0lV23ZFtyfJ+F2Ayyr0K4VSNf8ArUnFcpKO250LRUQABoAYLkhzXdWHMA5u26uNGXF9tx8uOusfYlbTcC6aaqrdTTnTlHVfQrHJF6PRsBwAAC5i9vg0xbgaAIAAxtJVduosskVotWiTcpav6ANKXJ9the27sF9NyuLHTWXsA2OHBd3cYDQAAAAAAAwSeNS1syhgHNRxdJfUK10ujoaTuJLCrp0Amm1Ztdhv6z3Sfkxwmu4leqoBT+1P1f8AkZy4469daEaopllRqPRAH9ZOyp5Fbbu2+xmuyGUJvsAtaaWQUcnSP1Kxwq7dR0krALDGo63Y4GgAAAAAAAAAAAAAAAAAshJ7AAGK6B3YABsNx4gADAAAAAAAAAAAAAf/2Q==);
background-repeat: no-repeat;
background-position: center;
background-size: 50% 50%;
& > img {
display: none;
}
}
}
}
> .loading {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 6px;
z-index: 999;
&.first {
bottom: 50%;
transform: translate(-50%, 50%);
}
&.ball-beat {
> .dot {
vertical-align: bottom;
background-color: #4b15ab;
width: 12px;
height: 12px;
border-radius: 50%;
margin: 3px;
animation-fill-mode: both;
display: inline-block;
animation: loading 0.7s 0s infinite linear;
&:nth-child(2n-1) {
animation-delay: 0.35s;
}
}
}
@keyframes loading {
50% {
opacity: 0.2;
transform: scale(0.75);
}
100% {
opacity: 1;
transform: scale(1);
}
}
}
}
</style>
6. 父组件App.vue调用
js
<template>
<Waterfall
:list="list"
@scrollReachBottom="handleReachBottom"
:imgWidth="300"
@onClick="handleClick"
:width="'100vw'"
:height="'100vh'"
>
<!-- 自定义底部文案 -->
<template v-slot="{ data }">
<div style="width: 100%; height: 50px; overflow: hidden">
<div>{{ data.info }}</div>
</div>
</template>
</Waterfall>
</template>
<script setup lang="ts">
import { reactive } from "vue";
import Waterfall from "@/components/Waterfall.vue";
import imgs from "./imgs";
type ImageType = (typeof imgs)[0];
const list = reactive(imgs);
//触底之后添加数据
const handleReachBottom = () => {
list.push(...imgs);
};
//图片的点击事件
const handleClick = (item: ImageType) => {
console.log(item);
};
</script>
<style>
body {
margin: 0;
}
</style>
7. 最后
本文展示了PC端使用JS实现瀑布流布局的核心思想,只要明白了核心思想,后期再加上防抖节流以及移动端等适配的业务代码就不难了。
希望各位点赞+收藏支持下,能被各位认可,也是我创作的动力。
项目demo Github地址:github.com/cwjbjy/Wate...