I am using Map from react-map-gl to display several markers in a map. I also use Layers and Source to have my markers in clusters. But I have a problem because I can have multiple points with the exact same coordinate and those points end up overlaying each other when zooming in (it seems that there is only one marker in the position when there are multiple).
I have installed mapboxgl-spiderifier to overcome this problem but I can't seem to get it working.
Here's some of my code:
const [spiderifier, setSpiderifier] = useState(null);
const ref = useRef();
...
const onMapLoad = React.useCallback(() => {
setSpiderifier(
new MapboxglSpiderifier(ref.current.getMap(), {
onClick: function (e, spiderLeg) {
e.stopPropagation();
console.log("Clicked on ", spiderLeg);
},
markerWidth: 100,
markerHeight: 100,
})
);
}, []);
const onClick = (event) => {
const feature = event.features[0];
if (feature.layer.id === "clusters") {
const clusterId = feature.properties.cluster_id;
const mapboxSource = ref.current.getSource("classesandtrainers");
if (location.zoom >= 12) {
mapboxSource.getClusterLeaves(
clusterId,
100,
0,
function (err, leafFeatures) {
if (err) {
return console.error("error while getting leaves of a cluster", err);
}
let markers = leafFeatures.map((leafFeature) => {
return leafFeature.properties;
});
spiderifier.spiderfy(event.lngLat, { ...geoJson, features: markers });
}
);
return;
}
mapboxSource.getClusterExpansionZoom(clusterId, (err, zoom) => {
if (err) {
return;
}
ref.current.easeTo({
center: feature.geometry.coordinates,
zoom,
duration: 500,
});
});
}
};
...
return (
<Map
ref={ref}
{...location}
onLoad={onMapLoad}
onMove={(evt) => setLocation(evt.viewState)}
onClick={onClick}
mapStyle="mapbox://styles/flxbl/cl92sjxf4001g15la7upwjij2"
mapboxAccessToken={process.env.mapbox_key}
style={{ width: "100%", height: "100%", margin: 0, padding: 0 }}
interactiveLayerIds={[clusterLayer.id, unclusteredPointLayer.id]}
>
<Source
id="classesandtrainers"
type="geojson"
data={sourceData()}
cluster={true}
clusterMaxZoom={14}
clusterRadius={50}
>
<Layer {...clusterLayer} />
<Layer {...clusterCountLayer} />
<Layer {...unclusteredPointLayer} />
</Source>
<ScaleControl position="bottom-right" />
{renderPopup()}
</Map>
Can someone please help me?
Related
I want to paint several clusters red, but when zoom the red flags disappear. I code on vue3 optionAPI.
script.js
import View from 'ol/View'
import Map from 'ol/Map'
import TileLayer from 'ol/layer/Tile'
import OSM from 'ol/source/OSM'
import VectorLayer from 'ol/layer/Vector'
import Point from 'ol/geom/Point';
import LineString from 'ol/geom/LineString';
import VectorSource from 'ol/source/Vector';
import Feature from 'ol/Feature'
import {
Circle as CircleStyle,
Fill,
Stroke,
Style,
Text,
} from 'ol/style';
import Overlay from 'ol/Overlay';
import Cluster from "ol/source/Cluster"
import { useGeographic } from 'ol/proj';
import 'ol/ol.css'
import './style.css'
import axios from 'axios'
export default {
name: 'MapContainer',
components: {},`your text`
props: {},
data() {
return {
place: [43.984506, 56.305298],
data: [],
featuresPoints: [],
featuresLines: [],
}
},
methods: {
async getData() {
axios("https://someRestApiLink.com").then(res => {
this.data = res.data;
this.createFeachers();
this.renderMap();
});
},
setCircleStyle(feature) {
const size = feature.get('features').length;
let style = styleCache[size];
if (!style) {
style = new Style({
image: new CircleStyle({
radius: 10,
stroke: new Stroke({
color: '#fff',
}),
fill: new Fill({
color: '#3399CC',
}),
}),
text: new Text({
text: size.toString(),
fill: new Fill({
color: '#fff',
}),
}),
});
styleCache[size] = style;
}
return style;
},
createFeachers() {
for (let item of this.data) {
let coords = JSON.parse(item.coords);
if (coords.length === 1) {
let feature = new Feature(new Point(coords[0].reverse()));
feature.mydata = item;
this.featuresPoints.push(feature);
} else {
let rightCoords = coords.map(el => el.reverse());
let isValidFeacture = true;
for (let i = 0; i < rightCoords.length - 1; i++) {
if (Math.abs(rightCoords[i][0] - rightCoords[i + 1][0]) > .01) {
isValidFeacture = false;
break
}
}
if (!isValidFeacture) continue;
let feature = new Feature({
geometry: new LineString(rightCoords)
});
feature.setStyle(new Style({
stroke: new Stroke({
color: '#0000ff',
width: 3
})
}))
feature.mydata = item
this.featuresLines.push(feature);
}
}
},
createMap() {
return new Map({
target: this.$refs['map-root'],
view: new View({
zoom: 12,
center: this.place
}),
layers: [
new TileLayer({
source: new OSM()
}),
this.createLineLayer(),
this.createPointLayer(),
],
});
},
createPointLayer() {
const styleCache = {};
let cluster = new Cluster({
distance: 15,
minDistance: 6,
source: new VectorSource({
features: this.featuresPoints,
})
});
const mainCluster = new VectorLayer({
source: cluster,
style: function (feature) {
function calculateFired(cluster, length) {
let pointList = cluster.values_.features;
let countFired = 0;
for (let point of pointList) {
if (point.mydata.status === "Просрочен") {
countFired++;
}
}
return countFired === length ? "full" : countFired > 0 ? "several" : "none";
}
const size = feature.get('features').length;
let style = styleCache[size];
if (!style) {
let hasFired = calculateFired(feature, size);
style = new Style({
image: new CircleStyle({
radius: 10,
stroke: new Stroke({
color: hasFired === "none" ? '#fff' : "#f00",
// color: '#fff',
}),
fill: new Fill({
color: hasFired === "full" ? "#f00" : '#3399CC',
// color: '#3399CC',
}),
}),
text: new Text({
text: size.toString(),
fill: new Fill({
color: '#fff',
}),
}),
});
styleCache[size] = style;
// console.log(style);
}
return style;
},
});
mainCluster.mydata = this.featuresPoints.map(el => el.mydata);
return mainCluster
},
createLineLayer() {
return new VectorLayer({
source: new VectorSource({
features: this.featuresLines,
}),
})
},
renderMap() {
useGeographic();
const createPopUp = this.createPopUp;
var container = document.getElementById("popup");
var content = document.getElementById("popup-content");
const map = this.createMap();
const overlay = new Overlay({
element: container,
autoPan: true
});
map.on('click', function (e) {
let pixel = map.getEventPixel(e.originalEvent);
if (document.getElementsByClassName('popup-content').length != 0) {
document.getElementsByClassName('popup-content')[0].style.display = 'none'
}
map.forEachFeatureAtPixel(pixel, function (feature) {
let data = feature.mydata ?? feature.values_.features.map(el => el.mydata);
let coodinate = e.coordinate;
content.innerHTML = createPopUp(data);
overlay.setPosition(coodinate);
map.addOverlay(overlay);
});
});
},
createPopUp(data) {
let content = "";
if (data.length) {
let sortedData = data.sort((a, b)=> {
if (a.time.split(".").reverse().join("-") > b.time.split(".").reverse().join("-")) return 1;
if (a.time.split(".").reverse().join("-") < b.time.split(".").reverse().join("-")) return -1;
return 0;
});
for (let el of sortedData) {
content += `
<div class="popup-content__valueBlock">
<div class="popup-content__valueBlock__organization">
${el.organization}:
</div>
<div class="popup-content__valueBlock__aimOfWorks">
Тип: ${el.aim_of_works}
</div>
<div class="popup-content__valueBlock__stripSurface">
Работы ведутся над: ${el.strip_surface}
</div>
<div class="popup-content__valueBlock__status">
${el.status} - ${el.finish_date}
</div>
</div>
`;
}
} else {
content = `
<div class="popup-content__valueBlock">
<div class="popup-content__valueBlock__organization">
${data.organization}:
</div>
<div class="popup-content__valueBlock__aimOfWorks">
Тип: ${data.aim_of_works}
</div>
<div class="popup-content__valueBlock__stripSurface">
Работы ведутся над: ${data.strip_surface}
</div>
<div class="popup-content__valueBlock__status">
${data.status} - ${data.time}
</div>
</div>
`;
}
return `
<div class="popup-content">
<span class="close" onclick="closePopup()">x</span>
<span class="count">Количество выбранных ордеров: ${data.length ?? 1}</span>
${content}
</div>
`;
},
},
mounted() {
this.getData();
},
}
I checked ol_uid of cluster when I rezoomed, and I was so surprised by changed this property at the same cluster. Also I tried to rerender map at every time when I changed zoom, but this not work too.
I think that I mb do something wrong on creating or rendering map or clusters.
This code is inside a .vue file. Receiving the "Error: Table has no columns." Inside the methods function, it won't receive the values from firebase to display a chart/graph to web app. What am I doing incorrectly? I think the values am I trying to receive inside "chartData:[ ]" or "mounted()" is incorrect and may be causing the issue. Any help is much appreciated.
export default {
name: "App",
components: {
GChart
},
methods: {
getHumidity(){
get(
query(ref(db, auth.currentUser.uid + "/Environment/humidity"),
orderByChild("humidity")
)
).then((snapshot) => {
if (snapshot.exists()) {
console.log(snapshot.val());
for (const item in snapshot.val()) {
this.pgoods.push({
humidity: snapshot.val()[item].humidity,
expir: snapshot.val()[item].humidity,
});
}
} else {
this.pgoods = [];
}
return float(snapshot.val());
});
}
getPressure(){
get(
query(ref(db, auth.currentUser.uid + "/Environment/pressure"),
orderByChild("pressure")
)
).then((snapshot) => {
if (snapshot.exists()) {
console.log(snapshot.val());
for (const item in snapshot.val()) {
this.pgoods.push({
pressure: snapshot.val()[item].pressure,
expir: snapshot.val()[item].pressure,
});
}
} else {
this.pgoods = [];
}
return float(snapshot.val());
});
}
getTime(){
get(
query(ref(db, auth.currentUser.uid + "/Environment/time"),
orderByChild("time")
)
).then((snapshot) => {
if (snapshot.exists()) {
console.log(snapshot.val());
for (const item in snapshot.val()) {
this.pgoods.push({
time: snapshot.val()[item].time,
expir: snapshot.val()[item].time,
});
}
} else {
this.pgoods = [];
}
return float(snapshot.val());
});
},
},
data(){
return{
chartData: [
["Time", "Pressure", "Humidity"],
[this.getTime(), this.getPressure(), this.getHumidity()],
[this.getTime(), this.getPressure(), this.getHumidity()],
],
},
mounted(){
this.getTemperature();
this.getHumidity();
},
}
I am using react-konva and I want to crop my selected image when edit button clicked.
Can anyone please guide me how I can achieve this ?
this is the Rect I am using to crop the portion of the image.
Here in this code onShapeChange function saves the crop value of the image in
canvas editor.
{(isCropping &&
<>
{React.createElement(`Rect`, {
ref: cropRef,
key: selectedShape.id,
id: selectedShape.id,
...selectedShape.attributes,
draggable: false,
onTransformEnd: (e) => {
const node = cropRef.current;
const scaleX = node.scaleX();
const scaleY = node.scaleY();
node.scaleX(1);
node.scaleY(1);
const newShape = {
...selectedShape,
attributes:
{
...selectedShape.attributes,
crop: {
x: node.x() - selectedShape.attributes.x,
y: node.y() - selectedShape.attributes.y,
// width: this.state.rect.attrs.width,
// height: this.state.rect.attrs.height
// x: node.x(),
// y: node.y(),
width: Math.max(5, node.width() * scaleX),
height: Math.max(node.height() * scaleY),
}
}
}
console.log('newShape in cropper', newShape, 'SelectedShape', selectedShape);
onShapeChange({
id: selectedShape.id,
index: selectedReportItem.index,
reportIndex: selectedReportItem.reportIndex,
newItem: newShape,
})
setIsCropping(false);
}
}, null)}
<Transformer
ref={croptrRef}
rotateEnabled={false}
flipEnabled={false}
boundBoxFunc={(oldBox, newBox) => {
// limit resize
if (newBox.width < 5 || newBox.height < 5) {
return oldBox;
}
return newBox;
}}
/>
</>
}
I want to make a slide show in framer motion and I found that in framer motion docs they have an example slide show like this https://codesandbox.io/s/framer-motion-image-gallery-pqvx3?from-embed=&file=/src/Example.tsx, but I found a bug when we drag and double click it, it will be stuck like this picture .
import * as React from "react";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { wrap } from "popmotion";
import { images } from "./image-data";
const variants = {
enter: (direction: number) => {
return {
x: direction > 0 ? 1000 : -1000,
opacity: 0
};
},
center: {
zIndex: 1,
x: 0,
opacity: 1
},
exit: (direction: number) => {
return {
zIndex: 0,
x: direction < 0 ? 1000 : -1000,
opacity: 0
};
}
};
const swipeConfidenceThreshold = 10000;
const swipePower = (offset: number, velocity: number) => {
return Math.abs(offset) * velocity;
};
export const Example = () => {
const [[page, direction], setPage] = useState([0, 0]);images.
const imageIndex = wrap(0, images.length, page);
const paginate = (newDirection: number) => {
setPage([page + newDirection, newDirection]);
};
return (
<>
<AnimatePresence initial={false} custom={direction}>
<motion.img
key={page}
src={images[imageIndex]}
custom={direction}
variants={variants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.2 }
}}
drag="x"
dragConstraints={{ left: 0, right: 0 }}
dragElastic={1}
onDragEnd={(e, { offset, velocity }) => {
const swipe = swipePower(offset.x, velocity.x);
if (swipe < -swipeConfidenceThreshold) {
paginate(1);
} else if (swipe > swipeConfidenceThreshold) {
paginate(-1);
}
}}
/>
</AnimatePresence>
</>
);
};
I try to solve this problem but still can't fix it, can someone help me?
This looks like a bug of framer-motion.
Up until v1.6.2, everything works fine. The bug seems to occur in all later versions.
There is also an interesting changelog:
[1.6.3] 2019-08-19
Fixed
Ensuring onDragEnd always fires after if onDragStart fired.
Here is a link to the related issue on GitHub, opened by the author of this question.
Until that bug is fixed, here is a workaround that uses Pan events
export default function Carousel() {
const animationConfidenceThreshold = 200; // you have to move the element 200px in order to perform an animation
const [displayed, setDisplayed] = useState(0); // the index of the displayed element
const xOffset = useMotionValue(0); // this is the motion value that drives the translation
const lastOffset = useRef(0); // this is the lastValue of the xOffset after the Pan ended
const elementAnimatingIn = useRef(false); // this will be set to true whilst a new element is performing its animation to the center
useEffect(() => {
// this happens after we have dragged the element out and triggered a rerender
if (elementAnimatingIn.current) {
const rightPan = xOffset.get() > 0; // check if the user drags it to the right
// if the element has animated out to the right it animates in from the left
xOffset.set(
rightPan ? -1 * window.innerWidth - 200 : window.innerWidth + 200
);
// perform the animation to the center
animate(xOffset, 0, {
duration: 0.5,
onComplete: () => {
xOffset.stop();
},
onStop: () => {
elementAnimatingIn.current = false;
lastOffset.current = xOffset.get();
}
});
}
});
return (
<div className="container">
<motion.div
className="carouselElement"
onPan={(e, info) => {
xOffset.set(lastOffset.current + info.offset.x); // set the xOffset to the current offset of the pan + the prev offset
}}
style={{ x: xOffset }}
onPanStart={() => {
// check if xOffset is animating, if true stop animation and set lastOffset to current xOffset
if (xOffset.isAnimating()) {
xOffset.stop();
lastOffset.current = xOffset.get();
}
}}
onPanEnd={(e, info) => {
// there can be a difference between the info.offset.x in onPan and onPanEnd
// so we will set the xOffset to the info.offset.x when the pan ends
xOffset.set(lastOffset.current + info.offset.x);
lastOffset.current = xOffset.get(); // set the lastOffset to the current xOffset
if (Math.abs(lastOffset.current) < animationConfidenceThreshold) {
// if its only a small movement, animate back to the initial position
animate(xOffset, 0, {
onComplete: () => {
lastOffset.current = 0;
}
});
} else {
// perform the animation to the next element
const rightPan = xOffset.get() > 0; // check if the user drags it to the right
animate(
xOffset,
rightPan ? window.innerWidth + 200 : -1 * window.innerWidth - 200, // animate out of view
{
duration: 0.5,
onComplete: () => {
// after the element has animated out
// stop animation (it does not do this on its own, only one animation can happen at a time)
xOffset.stop();
elementAnimatingIn.current = true;
// trigger a rerender with the new content - now the useEffect runs
setDisplayed(rightPan ? displayed - 1 : displayed + 1);
}
}
);
}
}}
>
<span style={{ userSelect: "none" }}>
{"I am element #" + displayed}
</span>
</motion.div>
</div>
);
}
Check this codesandbox out!
I have a side menu and when it's open, the body can be partially seen. My side menu might be long so you could scroll on it. But when the menu is at the bottom you then scroll on the body, and I don't want this behaviour.
Similar to Scrolling only content div, others should be fixed but I'm using React. Other content should be scrollable when my side menu is closed. Think of the content as side menu in the example in the link. So far I'm using the same technique provided by that answer but it's ugly (kinda jQuery):
preventOverflow = (menuOpen) => { // this is called when side menu is toggled
const body = document.getElementsByTagName('body')[0]; // this should be fixed when side menu is open
if (menuOpen) {
body.className += ' overflow-hidden';
} else {
body.className = body.className.replace(' overflow-hidden', '');
}
}
// css
.overflow-hidden {
overflow-y: hidden;
}
What should I do with Reactjs?
You should make a meta component in react to change things on the body as well as changing things like document title and things like that. I made one a while ago to do that for me. I'll add it here.
Usage
render() {
return (
<div>
<DocumentMeta bodyClasses={[isMenuOpen ? 'no-scroll' : '']} />
... rest of your normal code
</div>
)
}
DocumentMeta.jsx
import React from 'react';
import _ from 'lodash';
import withSideEffect from 'react-side-effect';
var HEADER_ATTRIBUTE = "data-react-header";
var TAG_NAMES = {
META: "meta",
LINK: "link",
};
var TAG_PROPERTIES = {
NAME: "name",
CHARSET: "charset",
HTTPEQUIV: "http-equiv",
REL: "rel",
HREF: "href",
PROPERTY: "property",
CONTENT: "content"
};
var getInnermostProperty = (propsList, property) => {
return _.result(_.find(propsList.reverse(), property), property);
};
var getTitleFromPropsList = (propsList) => {
var innermostTitle = getInnermostProperty(propsList, "title");
var innermostTemplate = getInnermostProperty(propsList, "titleTemplate");
if (innermostTemplate && innermostTitle) {
return innermostTemplate.replace(/\%s/g, innermostTitle);
}
return innermostTitle || "";
};
var getBodyIdFromPropsList = (propsList) => {
var bodyId = getInnermostProperty(propsList, "bodyId");
return bodyId;
};
var getBodyClassesFromPropsList = (propsList) => {
return propsList
.filter(props => props.bodyClasses && Array.isArray(props.bodyClasses))
.map(props => props.bodyClasses)
.reduce((classes, list) => classes.concat(list), []);
};
var getTagsFromPropsList = (tagName, uniqueTagIds, propsList) => {
// Calculate list of tags, giving priority innermost component (end of the propslist)
var approvedSeenTags = {};
var validTags = _.keys(TAG_PROPERTIES).map(key => TAG_PROPERTIES[key]);
var tagList = propsList
.filter(props => props[tagName] !== undefined)
.map(props => props[tagName])
.reverse()
.reduce((approvedTags, instanceTags) => {
var instanceSeenTags = {};
instanceTags.filter(tag => {
for(var attributeKey in tag) {
var value = tag[attributeKey].toLowerCase();
var attributeKey = attributeKey.toLowerCase();
if (validTags.indexOf(attributeKey) == -1) {
return false;
}
if (!approvedSeenTags[attributeKey]) {
approvedSeenTags[attributeKey] = [];
}
if (!instanceSeenTags[attributeKey]) {
instanceSeenTags[attributeKey] = [];
}
if (!_.has(approvedSeenTags[attributeKey], value)) {
instanceSeenTags[attributeKey].push(value);
return true;
}
return false;
}
})
.reverse()
.forEach(tag => approvedTags.push(tag));
// Update seen tags with tags from this instance
_.keys(instanceSeenTags).forEach((attr) => {
approvedSeenTags[attr] = _.union(approvedSeenTags[attr], instanceSeenTags[attr])
});
instanceSeenTags = {};
return approvedTags;
}, []);
return tagList;
};
var updateTitle = title => {
document.title = title || document.title;
};
var updateBodyId = (id) => {
document.body.setAttribute("id", id);
};
var updateBodyClasses = classes => {
document.body.className = "";
classes.forEach(cl => {
if(!cl || cl == "") return;
document.body.classList.add(cl);
});
};
var updateTags = (type, tags) => {
var headElement = document.head || document.querySelector("head");
var existingTags = headElement.querySelectorAll(`${type}[${HEADER_ATTRIBUTE}]`);
existingTags = Array.prototype.slice.call(existingTags);
// Remove any duplicate tags
existingTags.forEach(tag => tag.parentNode.removeChild(tag));
if (tags && tags.length) {
tags.forEach(tag => {
var newElement = document.createElement(type);
for (var attribute in tag) {
if (tag.hasOwnProperty(attribute)) {
newElement.setAttribute(attribute, tag[attribute]);
}
}
newElement.setAttribute(HEADER_ATTRIBUTE, "true");
headElement.insertBefore(newElement, headElement.firstChild);
});
}
};
var generateTagsAsString = (type, tags) => {
var html = tags.map(tag => {
var attributeHtml = Object.keys(tag)
.map((attribute) => {
const encodedValue = HTMLEntities.encode(tag[attribute], {
useNamedReferences: true
});
return `${attribute}="${encodedValue}"`;
})
.join(" ");
return `<${type} ${attributeHtml} ${HEADER_ATTRIBUTE}="true" />`;
});
return html.join("\n");
};
var reducePropsToState = (propsList) => ({
title: getTitleFromPropsList(propsList),
metaTags: getTagsFromPropsList(TAG_NAMES.META, [TAG_PROPERTIES.NAME, TAG_PROPERTIES.CHARSET, TAG_PROPERTIES.HTTPEQUIV, TAG_PROPERTIES.CONTENT], propsList),
linkTags: getTagsFromPropsList(TAG_NAMES.LINK, [TAG_PROPERTIES.REL, TAG_PROPERTIES.HREF], propsList),
bodyId: getBodyIdFromPropsList(propsList),
bodyClasses: getBodyClassesFromPropsList(propsList),
});
var handleClientStateChange = ({title, metaTags, linkTags, bodyId, bodyClasses}) => {
updateTitle(title);
updateTags(TAG_NAMES.LINK, linkTags);
updateTags(TAG_NAMES.META, metaTags);
updateBodyId(bodyId);
updateBodyClasses(bodyClasses)
};
var mapStateOnServer = ({title, metaTags, linkTags}) => ({
title: HTMLEntities.encode(title),
meta: generateTagsAsString(TAG_NAMES.META, metaTags),
link: generateTagsAsString(TAG_NAMES.LINK, linkTags)
});
var DocumentMeta = React.createClass({
propTypes: {
title: React.PropTypes.string,
titleTemplate: React.PropTypes.string,
meta: React.PropTypes.arrayOf(React.PropTypes.object),
link: React.PropTypes.arrayOf(React.PropTypes.object),
children: React.PropTypes.oneOfType([
React.PropTypes.object,
React.PropTypes.array
]),
bodyClasses: React.PropTypes.array,
},
render() {
if (Object.is(React.Children.count(this.props.children), 1)) {
return React.Children.only(this.props.children);
} else if (React.Children.count(this.props.children) > 1) {
return (
<span>
{this.props.children}
</span>
);
}
return null;
},
});
DocumentMeta = withSideEffect(reducePropsToState, handleClientStateChange, mapStateOnServer)(DocumentMeta);
module.exports = DocumentMeta;
This component could probably be changed a little for your case (withSideEffect is used for both client and server side rendering... if you arent using server side rendering then its probably not completely necessary) but the component will work on client side rendering if you would like to use it there as well.
ReactJS doesn't have direct access to the <body> element, and that's the element that needs to have its overflow-y style changed. So while what you're doing isn't perhaps the prettiest code, it's not entirely wrong either.
The only real suggestion I'd give is (shudder) using inline styles on the body instead of a classname so as to avoid having to introduce the CSS declaration. As long as your menu is the only thing responsible for updating the overflow-y attribute, there's no reason you can't use an inline style on it. Mashing that down with the ?: operator results in fairly simple code:
body.style.overflowY = menuOpen ? "hidden" : "";
And then you can just delete the .overflow-hidden class in its entirety.
If for some reason multiple things are managing the overflow state of the body, you might want to stick with classnames and assign a unique one for each thing managing it, something like this:
if (menuOpen) {
body.className += ' menu-open';
}
else {
// Use some tricks from jQuery to remove the "menu-open" class more elegantly.
var className = " " + body.className + " ";
className = className.replace(" overflow-hidden ", " ").replace(/\s+/, " ");
className = className.substr(1, className.length - 2);
}
CSS:
body.menu-open {
overflow-y: hidden;
}